From 607bba30d62df3a49abd2a1772c615381bc9a9e2 Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Mon, 7 Sep 2026 22:25:31 +0300 Subject: [PATCH 01/27] feat(template): port form extensions bundle from production Port the shared Reatom form extensions proven in the production app (easysell/integrations-web) into the template, mirrored byte-identical into apps/demo, with the login form as the in-app demonstration: - withFormSubmitHandler: host-event submit action, replacing the hand-rolled preventDefault/submit() bridge in LoginPage - formAlertMessage: gates the form-level alert to failures no field owns, replacing the raw submit.error() read in LoginPage - withFormAutoFocusOnError, withFormScrollToErrorOnReject, withSavedState, visibleFieldError: shipped API; demo consumers land with the settings/card and API-validation ports (fallow-suppressed) The comments at the decision points are the invention and are kept: rebaseline-via-init-not-reset so in-flight edits survive dirty, alerts never repeat field-owned failures, visible errors read `triggered`. Deviations from source: - Tests drive the Standard Schema contract with a structural inline schema instead of valibot: the template ships no validator dep and the port must not add one; reatomForm only calls schema['~standard'].validate and maps issues by path. - withSavedState comment restates the single-owner rule without citing the production repo's ban-resetOnSubmit.sh script. - Scroll-to-error comment's domain instances generalized to the problem class (array-shaped fields with no single input ref). - No Storybook story: state-only mechanism; the demo's Auth story exercises the wired login alert. Template unit suite: 8/8. Demo typecheck, lint, steiger, fallow dead-code green. The storybook browser project fails on a pre-existing path-to-regexp dep drift (baseline, unrelated). --- apps/demo/src/pages/login/model/routes.tsx | 3 +- apps/demo/src/pages/login/ui/LoginPage.tsx | 14 +- apps/demo/src/shared/reatom/forms.test.ts | 216 ++++++++++++++++++ apps/demo/src/shared/reatom/forms.ts | 202 ++++++++++++++++ apps/demo/src/shared/reatom/index.ts | 12 + .../template/src/pages/login/model/routes.tsx | 3 +- .../template/src/pages/login/ui/LoginPage.tsx | 14 +- .../template/src/shared/reatom/forms.test.ts | 216 ++++++++++++++++++ .../template/src/shared/reatom/forms.ts | 202 ++++++++++++++++ .../template/src/shared/reatom/index.ts | 12 + 10 files changed, 880 insertions(+), 14 deletions(-) create mode 100644 apps/demo/src/shared/reatom/forms.test.ts create mode 100644 apps/demo/src/shared/reatom/forms.ts create mode 100644 packages/create-karkas/template/src/shared/reatom/forms.test.ts create mode 100644 packages/create-karkas/template/src/shared/reatom/forms.ts diff --git a/apps/demo/src/pages/login/model/routes.tsx b/apps/demo/src/pages/login/model/routes.tsx index 7e675e32..63776ddb 100644 --- a/apps/demo/src/pages/login/model/routes.tsx +++ b/apps/demo/src/pages/login/model/routes.tsx @@ -2,6 +2,7 @@ import { urlAtom, reatomForm } from '@reatom/core' import { Fragment } from 'react' import { isAuthenticatedAtom, loginAction } from '#entities/auth' +import { withFormSubmitHandler } from '#shared/reatom' import { createAppPath, rootRoute } from '#shared/router' import { LoginPage } from '../ui/LoginPage' @@ -12,7 +13,7 @@ const reatomLoginForm = () => reatomForm( { email: 'alex@example.com', password: 'password' }, { name: 'loginForm', onSubmit: loginAction }, - ) + ).extend(withFormSubmitHandler()) export type LoginForm = ReturnType diff --git a/apps/demo/src/pages/login/ui/LoginPage.tsx b/apps/demo/src/pages/login/ui/LoginPage.tsx index c2c96324..c95a6705 100644 --- a/apps/demo/src/pages/login/ui/LoginPage.tsx +++ b/apps/demo/src/pages/login/ui/LoginPage.tsx @@ -5,11 +5,16 @@ import { bindField, reatomComponent } from '@reatom/react' import { m } from '#paraglide/messages.js' import { Alert, Button, Field, Heading, Input, Text } from '#shared/components' +import { formAlertMessage } from '#shared/reatom' import { styled } from '#styled-system/jsx' export const LoginPage = reatomComponent(({ form }: { form: LoginForm }) => { const { fields, submit } = form - const error = submit.error() + // `formAlertMessage`, not `submit.error()` directly: it stays null while a + // field-level validation owns the failure, so the alert never repeats a + // message already printed under a field. The copy here is deliberately + // canned — ApiError.message is a status string, not user-facing text. + const showErrorAlert = formAlertMessage(form) !== null const pending = !submit.ready() return ( @@ -25,17 +30,14 @@ export const LoginPage = reatomComponent(({ form }: { form: LoginForm }) => { display="flex" flexDirection="column" gap="5" - onSubmit={wrap((event) => { - event.preventDefault() - submit() - })} + onSubmit={wrap(form.handleSubmit)} > {m.login_title()} {m.login_description()} - {error && ( + {showErrorAlert && ( diff --git a/apps/demo/src/shared/reatom/forms.test.ts b/apps/demo/src/shared/reatom/forms.test.ts new file mode 100644 index 00000000..0226a377 --- /dev/null +++ b/apps/demo/src/shared/reatom/forms.test.ts @@ -0,0 +1,216 @@ +import { clearStack, context, reatomField, reatomForm, STACK, wrap } from '@reatom/core' +import { afterEach, expect, test } from 'vite-plus/test' + +import { + formAlertMessage, + visibleFieldError, + withFormAutoFocusOnError, + withFormSubmitHandler, + withSavedState, +} from './forms' + +afterEach(() => context.reset()) + +// `submit.onFulfill` hooks run in the next cleanup-queue tick, not synchronously +// with the awaited submit promise, so every assertion about post-save state has +// to let the queue flush first. +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +type TestValues = { title: string } + +// The template ships no schema validator, so the schema-path tests drive the +// Standard Schema contract directly — reatomForm only calls +// `schema['~standard'].validate(state)` and maps the returned issues by path, +// so a minimal structural implementation stands in for a validator library. +// Typed structurally against the reatomForm `schema` option rather than +// @standard-schema/spec, which is only a transitive (non-importable) dependency. +type TestSchema = { + '~standard': { + version: 1 + vendor: 'test' + validate: ( + value: unknown, + ) => { value: State } | { issues: ReadonlyArray<{ message: string; path?: string[] }> } + } +} + +const requiredFieldsSchema = >( + fields: ReadonlyArray<[keyof State, string]>, +): TestSchema => ({ + '~standard': { + version: 1, + vendor: 'test', + validate: (value) => { + const state = value as State + const issues = fields.flatMap(([key, message]) => + (state[key] ?? '').trim() ? [] : [{ message, path: [String(key)] }], + ) + return issues.length > 0 ? { issues } : { value: state } + }, + }, +}) + +const reatomTestForm = ( + onSubmit: (values: TestValues) => Promise, + options?: Parameters[0], +) => reatomForm({ title: 'saved' }, { name: 'test.form', onSubmit }).extend(withSavedState(options)) + +test('a saved form rebaselines on the submitted values and reports itself clean', async () => { + const form = reatomTestForm(async (values) => values) + + form.fields.title.change('edited') + expect(form.focus().dirty).toBe(true) + + await form.submit() + await flush() + + expect(form.fields.title.value()).toBe('edited') + expect(form.focus().dirty).toBe(false) +}) + +// The failure this guards against: rebaselining on the form's LIVE state would +// adopt whatever was typed while the request was in flight as "saved", so an +// edit that never reached the server would read as clean — and the disclosure +// card would collapse over it, showing a summary row with the old value. +test('an edit typed while the save is in flight stays unsaved', async () => { + let release: (() => void) | undefined + const inFlight = new Promise((resolve) => { + release = resolve + }) + const form = reatomTestForm(async (values) => { + await wrap(inFlight) + return values + }) + + form.fields.title.change('sent') + const submitted = form.submit() + // Typed after the request left, so it was never part of the payload. + form.fields.title.change('typed during save') + release?.() + await submitted + await flush() + + expect(form.fields.title.value()).toBe('typed during save') + expect(form.focus().dirty).toBe(true) +}) + +test('onSaved runs once the save resolves, and not on a failed one', async () => { + let saved = 0 + const form = reatomTestForm(async (values) => values, { onSaved: () => (saved += 1) }) + form.fields.title.change('ok') + await form.submit() + await flush() + expect(saved).toBe(1) + + const failing = reatomTestForm( + async () => { + throw new Error('nope') + }, + { onSaved: () => (saved += 1) }, + ) + failing.fields.title.change('boom') + await failing.submit().catch(() => {}) + await flush() + expect(saved).toBe(1) +}) + +test('returning nothing clears a write-only form back to its initial baseline', async () => { + // Write-only forms start empty (nothing to read back) and return null so + // `withSavedState` resets them rather than rebaselining on the secret. + const form = reatomForm( + { secret: '' }, + { + name: 'test.writeOnly', + onSubmit: async () => null, + }, + ).extend(withSavedState()) + form.fields.secret.change('typed-secret') + await form.submit() + await flush() + + expect(form.fields.secret.value()).toBe('') + expect(form.focus().dirty).toBe(false) +}) + +test('form alert keeps a failure visible when no field owns it', async () => { + const form = reatomTestForm(async () => { + throw new Error('Request failed') + }) + + await form.submit().catch(() => {}) + expect(form.validation().errors).toHaveLength(0) + expect(formAlertMessage(form)).toBe('Request failed') +}) + +test('a form schema blocks onSubmit and owns the visible field error', async () => { + let submits = 0 + const form = reatomForm( + { title: reatomField('', { name: 'test.validated.title' }) }, + { + name: 'test.validated', + validateOnBlur: true, + keepErrorOnChange: false, + schema: requiredFieldsSchema([['title', 'Required']]), + onSubmit: (values) => { + submits += 1 + return values + }, + }, + ).extend(withFormSubmitHandler()) + + await form.submit().catch(() => {}) + expect(submits).toBe(0) + expect(visibleFieldError(form.fields.title)).toBe('Required') + expect(formAlertMessage(form)).toBeNull() + + form.fields.title.set('fixed') + expect(visibleFieldError(form.fields.title)).toBeUndefined() + expect(formAlertMessage(form)).toBeNull() + form.fields.title.focus.in() + form.fields.title.focus.out() + await flush() + + await form.submit() + expect(submits).toBe(1) +}) + +test('submit handler prevents native submission and delegates to form validation', async () => { + let prevented = 0 + let submits = 0 + const form = reatomForm( + { title: 'ready' }, + { name: 'test.submitHandler', onSubmit: () => (submits += 1) }, + ).extend(withFormSubmitHandler()) + const hostHandler = wrap(form.handleSubmit) + const testStack = [...STACK] + clearStack() + try { + hostHandler({ preventDefault: () => (prevented += 1) }) + } finally { + STACK.push(...testStack) + } + await flush() + + expect(prevented).toBe(1) + expect(submits).toBe(1) +}) + +test('auto focus targets the first invalid field after rejected submit', async () => { + let focused = 0 + const form = reatomForm( + { first: '', second: '' }, + { + name: 'test.autoFocus', + schema: requiredFieldsSchema<{ first: string; second: string }>([ + ['first', 'First is required'], + ['second', 'Second is required'], + ]), + }, + ).extend(withFormAutoFocusOnError()) + form.fields.first.elementRef.set({ focus: () => (focused += 1) }) + + await form.submit().catch(() => {}) + await flush() + + expect(focused).toBe(1) +}) diff --git a/apps/demo/src/shared/reatom/forms.ts b/apps/demo/src/shared/reatom/forms.ts new file mode 100644 index 00000000..4d568a92 --- /dev/null +++ b/apps/demo/src/shared/reatom/forms.ts @@ -0,0 +1,202 @@ +import type { + Action, + AtomLike, + Computed, + FieldAtom, + FieldFocus, + Form, + SubmitAction, +} from '@reatom/core' + +import { action, atom, withCallHook } from '@reatom/core' + +type PreventableEvent = { preventDefault(): void } + +/** + * Adds a host-event submit action so ordinary forms do not repeat DOM bridging boilerplate. + * Adapted from reatom/reusables `with-form-submit-handler`. + * The component must still pass `wrap(form.handleSubmit)` to its host-managed form element. + */ +export const withFormSubmitHandler = + () => + >(form: Target) => { + const submitValidationError = atom(false, `${form.name}.submitValidationError`) + form.submit.onReject.extend( + withCallHook(() => submitValidationError.set(form.validation().errors.length > 0)), + ) + form.submit.onFulfill.extend(withCallHook(() => submitValidationError.set(false))) + + return { + handleSubmit: action((event?: PreventableEvent) => { + event?.preventDefault() + form.submit() + }, `${form.name}.handleSubmit`), + submitValidationError, + } + } + +/** + * Focuses the first invalid field after submit rejection when its element ref is connected. + * Adapted from reatom/reusables `with-form-auto-focus-on-error`. + */ +export const withFormAutoFocusOnError = + () => + >(form: Target): Target => { + form.submit.onReject.extend( + withCallHook(() => { + form + .fieldsList() + .find((field) => field.validation().error) + ?.elementRef() + ?.focus() + }), + ) + return form + } + +/** + * Scrolls the first invalid field or section-level error into view when + * submit is rejected. + * + * `withFormAutoFocusOnError` only reaches fields with a wired `elementRef`. + * Fields that validate a whole array of row objects (entry lists, item + * mappings, …) have no single input to point an `elementRef` at, so their + * error renders below the row list instead as a plain `role="alert"` element + * — and every Ark UI `Field.Root` already stamps a `data-invalid` attribute + * on itself (and its descendants) when invalid, boolean-style — present with + * an empty value, not `data-invalid="true"` — so `[data-invalid]` (not + * `[data-invalid="true"]`) is what actually matches. With no wiring required, + * this scrolls to whichever of the two the submit rejection left on screen — + * the first invalid thing in document order — so a multi-section wizard form + * no longer strands the user next to the submit button while the actual + * unfilled field sits off-screen above the fold. Runs after a frame so the + * rejection's error text has painted first. + */ +// fallow-ignore-next-line unused-export +export const withFormScrollToErrorOnReject = + () => + >(form: Target): Target => { + form.submit.onReject.extend( + withCallHook(() => { + requestAnimationFrame(() => { + const target = document.querySelector('[data-invalid], [role="alert"]') + target?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + target + ?.querySelector('input, select, button, textarea') + ?.focus({ preventScroll: true }) + }) + }), + ) + return form + } + +/** The structural slice of a `reatomForm` this extension drives. */ +type FormLike = AtomLike & { + focus: Computed + submit: SubmitAction + init: Action + reset: Action +} + +/** The structural slice `formAlertMessage` reads. */ +type FormWithSubmitError = { + validation: () => { errors: ReadonlyArray } + submit: { error: () => Error | null | undefined } + submitValidationError?: () => boolean +} + +/** + * The message a form-level alert may show, or `null` for no alert. + * + * The alert exists for failures no field can carry — a network error, a server + * message about the record as a whole. Two kinds of failure reach `submit.error` + * that are already on the fields, and repeating them there prints the same + * sentence twice: + * + * A field validation failure reaches `submit.error` as a plain Error carrying a + * copy of the first field message. API validation can do the same after its + * issues are mapped onto fields. In either case, the aggregate validation state + * tells us the message already has a field-level owner. + * + * A mapped API error outlives the field error it produced: editing the field + * clears its validation entry, but `submit.error()` keeps the rejected request + * until a submit succeeds. Callers that map a submit error elsewhere pass an + * `isHandled` predicate so that stale request error cannot migrate into this + * alert. Errors no field or mapper owns stay visible here. + */ +export function formAlertMessage(form: FormWithSubmitError, isHandled?: (error: Error) => boolean) { + const error = form.submit.error() + if (!error || form.submitValidationError?.() || isHandled?.(error)) return null + return form.validation().errors.length > 0 ? null : error.message +} + +/** + * The field error that should currently be visible to the user. + * + * `keepErrorOnChange: false` keeps the last issue in Reatom for bookkeeping but + * drops its `triggered` flag. Reading only `validation.error` would therefore + * leave stale copy visible while the user fixes the value. + */ +export function visibleFieldError(field: Pick, 'validation'>) { + const validation = field.validation() + return validation.triggered ? validation.error : undefined +} + +/** + * What a settings form must do once its save lands, so the UI stops claiming + * there is something left to save. The post-save state is driven ENTIRELY by + * what `onSubmit` RETURNS: + * + * - a truthy payload makes those values the form's new baseline, so it reads as + * clean: the submit CTA demotes, the reset affordance disappears, and the + * inputs keep showing what was saved. Applied with `init`, not `reset`. + * - returning nothing (or `null`) clears the form to its empty baseline with + * `reset` — for write-only credential forms, where the secret must not + * linger and there is nothing to read back. + * + * `onSaved` runs after that. Disclosure cards use it to collapse back to their + * summary rows — landing on rows that now state the new values is what confirms + * the save, without a toast claiming it. + * + * Two details matter about the rebaseline path: + * + * - the returned payload is what was actually persisted. Reading the form's + * live state at fulfilment instead would adopt anything typed while the + * request was in flight — edits that were never sent — as "saved". (Snapshot + * at call time does not work either: `withCallHook` on `submit` fires after + * the async body, by which point the state has already moved.) + * - `init` rebaselines without overwriting the current values, so an in-flight + * edit survives AND keeps the form dirty. `reset(values)` would clobber it — + * verified against @reatom/core. + * + * So a form with persistent values must `return` them from `onSubmit`; a + * write-only form returns nothing and is cleared by `reset`. Do not use the + * form's own `resetOnSubmit` option — this extension is the single place + * post-save clearing is decided, and two owners of that decision disagree. + * + * Applied to the form itself (not to a component) because both effects are + * state, not presentation: any card, drawer or page rendering the form gets + * the same post-save behaviour. + * + * Note: `reatomForm` clears `submit.data`/`submit.error` on reset, so never + * build a "saved" indicator on `submit.data()`. + */ +export const withSavedState = + ({ onSaved }: { onSaved?: () => void } = {}) => + (target: Target) => { + // `onFulfill` fires only on resolution — withAsync routes aborts to + // `onSettle` and failures to `onReject` — and its hook body already runs + // inside a Reatom frame, so no `wrap` is needed here. Its first param is + // whatever `onSubmit` returned. + target.submit.onFulfill.extend( + withCallHook((_call, [persisted]) => { + if (persisted) { + target.init(persisted) + } else { + target.reset() + } + onSaved?.() + }), + ) + return target + } diff --git a/apps/demo/src/shared/reatom/index.ts b/apps/demo/src/shared/reatom/index.ts index 8475d35a..5269bb3c 100644 --- a/apps/demo/src/shared/reatom/index.ts +++ b/apps/demo/src/shared/reatom/index.ts @@ -1 +1,13 @@ +export { + formAlertMessage, + // fallow-ignore-next-line unused-export + visibleFieldError, + withFormSubmitHandler, + // fallow-ignore-next-line unused-export + withFormAutoFocusOnError, + // fallow-ignore-next-line unused-export + withFormScrollToErrorOnReject, + // fallow-ignore-next-line unused-export + withSavedState, +} from './forms' export { withResizeObserver } from './resize-observer' diff --git a/packages/create-karkas/template/src/pages/login/model/routes.tsx b/packages/create-karkas/template/src/pages/login/model/routes.tsx index 7e675e32..63776ddb 100644 --- a/packages/create-karkas/template/src/pages/login/model/routes.tsx +++ b/packages/create-karkas/template/src/pages/login/model/routes.tsx @@ -2,6 +2,7 @@ import { urlAtom, reatomForm } from '@reatom/core' import { Fragment } from 'react' import { isAuthenticatedAtom, loginAction } from '#entities/auth' +import { withFormSubmitHandler } from '#shared/reatom' import { createAppPath, rootRoute } from '#shared/router' import { LoginPage } from '../ui/LoginPage' @@ -12,7 +13,7 @@ const reatomLoginForm = () => reatomForm( { email: 'alex@example.com', password: 'password' }, { name: 'loginForm', onSubmit: loginAction }, - ) + ).extend(withFormSubmitHandler()) export type LoginForm = ReturnType diff --git a/packages/create-karkas/template/src/pages/login/ui/LoginPage.tsx b/packages/create-karkas/template/src/pages/login/ui/LoginPage.tsx index c2c96324..c95a6705 100644 --- a/packages/create-karkas/template/src/pages/login/ui/LoginPage.tsx +++ b/packages/create-karkas/template/src/pages/login/ui/LoginPage.tsx @@ -5,11 +5,16 @@ import { bindField, reatomComponent } from '@reatom/react' import { m } from '#paraglide/messages.js' import { Alert, Button, Field, Heading, Input, Text } from '#shared/components' +import { formAlertMessage } from '#shared/reatom' import { styled } from '#styled-system/jsx' export const LoginPage = reatomComponent(({ form }: { form: LoginForm }) => { const { fields, submit } = form - const error = submit.error() + // `formAlertMessage`, not `submit.error()` directly: it stays null while a + // field-level validation owns the failure, so the alert never repeats a + // message already printed under a field. The copy here is deliberately + // canned — ApiError.message is a status string, not user-facing text. + const showErrorAlert = formAlertMessage(form) !== null const pending = !submit.ready() return ( @@ -25,17 +30,14 @@ export const LoginPage = reatomComponent(({ form }: { form: LoginForm }) => { display="flex" flexDirection="column" gap="5" - onSubmit={wrap((event) => { - event.preventDefault() - submit() - })} + onSubmit={wrap(form.handleSubmit)} > {m.login_title()} {m.login_description()} - {error && ( + {showErrorAlert && ( diff --git a/packages/create-karkas/template/src/shared/reatom/forms.test.ts b/packages/create-karkas/template/src/shared/reatom/forms.test.ts new file mode 100644 index 00000000..0226a377 --- /dev/null +++ b/packages/create-karkas/template/src/shared/reatom/forms.test.ts @@ -0,0 +1,216 @@ +import { clearStack, context, reatomField, reatomForm, STACK, wrap } from '@reatom/core' +import { afterEach, expect, test } from 'vite-plus/test' + +import { + formAlertMessage, + visibleFieldError, + withFormAutoFocusOnError, + withFormSubmitHandler, + withSavedState, +} from './forms' + +afterEach(() => context.reset()) + +// `submit.onFulfill` hooks run in the next cleanup-queue tick, not synchronously +// with the awaited submit promise, so every assertion about post-save state has +// to let the queue flush first. +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +type TestValues = { title: string } + +// The template ships no schema validator, so the schema-path tests drive the +// Standard Schema contract directly — reatomForm only calls +// `schema['~standard'].validate(state)` and maps the returned issues by path, +// so a minimal structural implementation stands in for a validator library. +// Typed structurally against the reatomForm `schema` option rather than +// @standard-schema/spec, which is only a transitive (non-importable) dependency. +type TestSchema = { + '~standard': { + version: 1 + vendor: 'test' + validate: ( + value: unknown, + ) => { value: State } | { issues: ReadonlyArray<{ message: string; path?: string[] }> } + } +} + +const requiredFieldsSchema = >( + fields: ReadonlyArray<[keyof State, string]>, +): TestSchema => ({ + '~standard': { + version: 1, + vendor: 'test', + validate: (value) => { + const state = value as State + const issues = fields.flatMap(([key, message]) => + (state[key] ?? '').trim() ? [] : [{ message, path: [String(key)] }], + ) + return issues.length > 0 ? { issues } : { value: state } + }, + }, +}) + +const reatomTestForm = ( + onSubmit: (values: TestValues) => Promise, + options?: Parameters[0], +) => reatomForm({ title: 'saved' }, { name: 'test.form', onSubmit }).extend(withSavedState(options)) + +test('a saved form rebaselines on the submitted values and reports itself clean', async () => { + const form = reatomTestForm(async (values) => values) + + form.fields.title.change('edited') + expect(form.focus().dirty).toBe(true) + + await form.submit() + await flush() + + expect(form.fields.title.value()).toBe('edited') + expect(form.focus().dirty).toBe(false) +}) + +// The failure this guards against: rebaselining on the form's LIVE state would +// adopt whatever was typed while the request was in flight as "saved", so an +// edit that never reached the server would read as clean — and the disclosure +// card would collapse over it, showing a summary row with the old value. +test('an edit typed while the save is in flight stays unsaved', async () => { + let release: (() => void) | undefined + const inFlight = new Promise((resolve) => { + release = resolve + }) + const form = reatomTestForm(async (values) => { + await wrap(inFlight) + return values + }) + + form.fields.title.change('sent') + const submitted = form.submit() + // Typed after the request left, so it was never part of the payload. + form.fields.title.change('typed during save') + release?.() + await submitted + await flush() + + expect(form.fields.title.value()).toBe('typed during save') + expect(form.focus().dirty).toBe(true) +}) + +test('onSaved runs once the save resolves, and not on a failed one', async () => { + let saved = 0 + const form = reatomTestForm(async (values) => values, { onSaved: () => (saved += 1) }) + form.fields.title.change('ok') + await form.submit() + await flush() + expect(saved).toBe(1) + + const failing = reatomTestForm( + async () => { + throw new Error('nope') + }, + { onSaved: () => (saved += 1) }, + ) + failing.fields.title.change('boom') + await failing.submit().catch(() => {}) + await flush() + expect(saved).toBe(1) +}) + +test('returning nothing clears a write-only form back to its initial baseline', async () => { + // Write-only forms start empty (nothing to read back) and return null so + // `withSavedState` resets them rather than rebaselining on the secret. + const form = reatomForm( + { secret: '' }, + { + name: 'test.writeOnly', + onSubmit: async () => null, + }, + ).extend(withSavedState()) + form.fields.secret.change('typed-secret') + await form.submit() + await flush() + + expect(form.fields.secret.value()).toBe('') + expect(form.focus().dirty).toBe(false) +}) + +test('form alert keeps a failure visible when no field owns it', async () => { + const form = reatomTestForm(async () => { + throw new Error('Request failed') + }) + + await form.submit().catch(() => {}) + expect(form.validation().errors).toHaveLength(0) + expect(formAlertMessage(form)).toBe('Request failed') +}) + +test('a form schema blocks onSubmit and owns the visible field error', async () => { + let submits = 0 + const form = reatomForm( + { title: reatomField('', { name: 'test.validated.title' }) }, + { + name: 'test.validated', + validateOnBlur: true, + keepErrorOnChange: false, + schema: requiredFieldsSchema([['title', 'Required']]), + onSubmit: (values) => { + submits += 1 + return values + }, + }, + ).extend(withFormSubmitHandler()) + + await form.submit().catch(() => {}) + expect(submits).toBe(0) + expect(visibleFieldError(form.fields.title)).toBe('Required') + expect(formAlertMessage(form)).toBeNull() + + form.fields.title.set('fixed') + expect(visibleFieldError(form.fields.title)).toBeUndefined() + expect(formAlertMessage(form)).toBeNull() + form.fields.title.focus.in() + form.fields.title.focus.out() + await flush() + + await form.submit() + expect(submits).toBe(1) +}) + +test('submit handler prevents native submission and delegates to form validation', async () => { + let prevented = 0 + let submits = 0 + const form = reatomForm( + { title: 'ready' }, + { name: 'test.submitHandler', onSubmit: () => (submits += 1) }, + ).extend(withFormSubmitHandler()) + const hostHandler = wrap(form.handleSubmit) + const testStack = [...STACK] + clearStack() + try { + hostHandler({ preventDefault: () => (prevented += 1) }) + } finally { + STACK.push(...testStack) + } + await flush() + + expect(prevented).toBe(1) + expect(submits).toBe(1) +}) + +test('auto focus targets the first invalid field after rejected submit', async () => { + let focused = 0 + const form = reatomForm( + { first: '', second: '' }, + { + name: 'test.autoFocus', + schema: requiredFieldsSchema<{ first: string; second: string }>([ + ['first', 'First is required'], + ['second', 'Second is required'], + ]), + }, + ).extend(withFormAutoFocusOnError()) + form.fields.first.elementRef.set({ focus: () => (focused += 1) }) + + await form.submit().catch(() => {}) + await flush() + + expect(focused).toBe(1) +}) diff --git a/packages/create-karkas/template/src/shared/reatom/forms.ts b/packages/create-karkas/template/src/shared/reatom/forms.ts new file mode 100644 index 00000000..4d568a92 --- /dev/null +++ b/packages/create-karkas/template/src/shared/reatom/forms.ts @@ -0,0 +1,202 @@ +import type { + Action, + AtomLike, + Computed, + FieldAtom, + FieldFocus, + Form, + SubmitAction, +} from '@reatom/core' + +import { action, atom, withCallHook } from '@reatom/core' + +type PreventableEvent = { preventDefault(): void } + +/** + * Adds a host-event submit action so ordinary forms do not repeat DOM bridging boilerplate. + * Adapted from reatom/reusables `with-form-submit-handler`. + * The component must still pass `wrap(form.handleSubmit)` to its host-managed form element. + */ +export const withFormSubmitHandler = + () => + >(form: Target) => { + const submitValidationError = atom(false, `${form.name}.submitValidationError`) + form.submit.onReject.extend( + withCallHook(() => submitValidationError.set(form.validation().errors.length > 0)), + ) + form.submit.onFulfill.extend(withCallHook(() => submitValidationError.set(false))) + + return { + handleSubmit: action((event?: PreventableEvent) => { + event?.preventDefault() + form.submit() + }, `${form.name}.handleSubmit`), + submitValidationError, + } + } + +/** + * Focuses the first invalid field after submit rejection when its element ref is connected. + * Adapted from reatom/reusables `with-form-auto-focus-on-error`. + */ +export const withFormAutoFocusOnError = + () => + >(form: Target): Target => { + form.submit.onReject.extend( + withCallHook(() => { + form + .fieldsList() + .find((field) => field.validation().error) + ?.elementRef() + ?.focus() + }), + ) + return form + } + +/** + * Scrolls the first invalid field or section-level error into view when + * submit is rejected. + * + * `withFormAutoFocusOnError` only reaches fields with a wired `elementRef`. + * Fields that validate a whole array of row objects (entry lists, item + * mappings, …) have no single input to point an `elementRef` at, so their + * error renders below the row list instead as a plain `role="alert"` element + * — and every Ark UI `Field.Root` already stamps a `data-invalid` attribute + * on itself (and its descendants) when invalid, boolean-style — present with + * an empty value, not `data-invalid="true"` — so `[data-invalid]` (not + * `[data-invalid="true"]`) is what actually matches. With no wiring required, + * this scrolls to whichever of the two the submit rejection left on screen — + * the first invalid thing in document order — so a multi-section wizard form + * no longer strands the user next to the submit button while the actual + * unfilled field sits off-screen above the fold. Runs after a frame so the + * rejection's error text has painted first. + */ +// fallow-ignore-next-line unused-export +export const withFormScrollToErrorOnReject = + () => + >(form: Target): Target => { + form.submit.onReject.extend( + withCallHook(() => { + requestAnimationFrame(() => { + const target = document.querySelector('[data-invalid], [role="alert"]') + target?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + target + ?.querySelector('input, select, button, textarea') + ?.focus({ preventScroll: true }) + }) + }), + ) + return form + } + +/** The structural slice of a `reatomForm` this extension drives. */ +type FormLike = AtomLike & { + focus: Computed + submit: SubmitAction + init: Action + reset: Action +} + +/** The structural slice `formAlertMessage` reads. */ +type FormWithSubmitError = { + validation: () => { errors: ReadonlyArray } + submit: { error: () => Error | null | undefined } + submitValidationError?: () => boolean +} + +/** + * The message a form-level alert may show, or `null` for no alert. + * + * The alert exists for failures no field can carry — a network error, a server + * message about the record as a whole. Two kinds of failure reach `submit.error` + * that are already on the fields, and repeating them there prints the same + * sentence twice: + * + * A field validation failure reaches `submit.error` as a plain Error carrying a + * copy of the first field message. API validation can do the same after its + * issues are mapped onto fields. In either case, the aggregate validation state + * tells us the message already has a field-level owner. + * + * A mapped API error outlives the field error it produced: editing the field + * clears its validation entry, but `submit.error()` keeps the rejected request + * until a submit succeeds. Callers that map a submit error elsewhere pass an + * `isHandled` predicate so that stale request error cannot migrate into this + * alert. Errors no field or mapper owns stay visible here. + */ +export function formAlertMessage(form: FormWithSubmitError, isHandled?: (error: Error) => boolean) { + const error = form.submit.error() + if (!error || form.submitValidationError?.() || isHandled?.(error)) return null + return form.validation().errors.length > 0 ? null : error.message +} + +/** + * The field error that should currently be visible to the user. + * + * `keepErrorOnChange: false` keeps the last issue in Reatom for bookkeeping but + * drops its `triggered` flag. Reading only `validation.error` would therefore + * leave stale copy visible while the user fixes the value. + */ +export function visibleFieldError(field: Pick, 'validation'>) { + const validation = field.validation() + return validation.triggered ? validation.error : undefined +} + +/** + * What a settings form must do once its save lands, so the UI stops claiming + * there is something left to save. The post-save state is driven ENTIRELY by + * what `onSubmit` RETURNS: + * + * - a truthy payload makes those values the form's new baseline, so it reads as + * clean: the submit CTA demotes, the reset affordance disappears, and the + * inputs keep showing what was saved. Applied with `init`, not `reset`. + * - returning nothing (or `null`) clears the form to its empty baseline with + * `reset` — for write-only credential forms, where the secret must not + * linger and there is nothing to read back. + * + * `onSaved` runs after that. Disclosure cards use it to collapse back to their + * summary rows — landing on rows that now state the new values is what confirms + * the save, without a toast claiming it. + * + * Two details matter about the rebaseline path: + * + * - the returned payload is what was actually persisted. Reading the form's + * live state at fulfilment instead would adopt anything typed while the + * request was in flight — edits that were never sent — as "saved". (Snapshot + * at call time does not work either: `withCallHook` on `submit` fires after + * the async body, by which point the state has already moved.) + * - `init` rebaselines without overwriting the current values, so an in-flight + * edit survives AND keeps the form dirty. `reset(values)` would clobber it — + * verified against @reatom/core. + * + * So a form with persistent values must `return` them from `onSubmit`; a + * write-only form returns nothing and is cleared by `reset`. Do not use the + * form's own `resetOnSubmit` option — this extension is the single place + * post-save clearing is decided, and two owners of that decision disagree. + * + * Applied to the form itself (not to a component) because both effects are + * state, not presentation: any card, drawer or page rendering the form gets + * the same post-save behaviour. + * + * Note: `reatomForm` clears `submit.data`/`submit.error` on reset, so never + * build a "saved" indicator on `submit.data()`. + */ +export const withSavedState = + ({ onSaved }: { onSaved?: () => void } = {}) => + (target: Target) => { + // `onFulfill` fires only on resolution — withAsync routes aborts to + // `onSettle` and failures to `onReject` — and its hook body already runs + // inside a Reatom frame, so no `wrap` is needed here. Its first param is + // whatever `onSubmit` returned. + target.submit.onFulfill.extend( + withCallHook((_call, [persisted]) => { + if (persisted) { + target.init(persisted) + } else { + target.reset() + } + onSaved?.() + }), + ) + return target + } diff --git a/packages/create-karkas/template/src/shared/reatom/index.ts b/packages/create-karkas/template/src/shared/reatom/index.ts index 8475d35a..5269bb3c 100644 --- a/packages/create-karkas/template/src/shared/reatom/index.ts +++ b/packages/create-karkas/template/src/shared/reatom/index.ts @@ -1 +1,13 @@ +export { + formAlertMessage, + // fallow-ignore-next-line unused-export + visibleFieldError, + withFormSubmitHandler, + // fallow-ignore-next-line unused-export + withFormAutoFocusOnError, + // fallow-ignore-next-line unused-export + withFormScrollToErrorOnReject, + // fallow-ignore-next-line unused-export + withSavedState, +} from './forms' export { withResizeObserver } from './resize-observer' From 5d981a82dccb2b8c0b9668abafbb900654851fae Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Mon, 7 Sep 2026 22:26:30 +0300 Subject: [PATCH 02/27] docs: add a solved-problems pattern catalog (repo index + site section) The template ships inventions, and inventions nobody can find don't exist. Two homes, two audiences: - docs/patterns.md: terse source-first index (pattern -> decision -> template/demo files), following docs/README.md conventions. The comments at the decision points stay the ground truth. - site/src/content/patterns/: the showcase front door. New Astro content collection rendered at /patterns (catalog) and /patterns/ (narrative with repo file pointers and a demo link), plus a Patterns section on the landing page. First entry: form save semantics, pointing at the forms bundle port and the wired login demo. --- docs/patterns.md | 63 +++++ site/src/content.config.ts | 23 +- .../content/patterns/form-save-semantics.md | 68 ++++++ site/src/lib/patterns.ts | 15 ++ site/src/pages/index.astro | 45 ++++ site/src/pages/patterns/[...slug].astro | 227 ++++++++++++++++++ site/src/pages/patterns/index.astro | 81 +++++++ 7 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 docs/patterns.md create mode 100644 site/src/content/patterns/form-save-semantics.md create mode 100644 site/src/lib/patterns.ts create mode 100644 site/src/pages/patterns/[...slug].astro create mode 100644 site/src/pages/patterns/index.astro diff --git a/docs/patterns.md b/docs/patterns.md new file mode 100644 index 00000000..3a33733b --- /dev/null +++ b/docs/patterns.md @@ -0,0 +1,63 @@ +# Patterns + +This doc follows the source-first approach in `docs/README.md`. + +## Overview + +The template ships solved problems, not just scaffolding: mechanisms proven in +production, ported with the decision that makes them correct. Each pattern is +demonstrated in the running demo app — code without a demo is a snippet, and +snippets rot. Long-form narratives live on the site (`site/src/content/patterns/`); +this doc is the terse index. The comments at the decision points in the source +files are the ground truth. + +For general Reatom usage conventions, see `docs/reatom-patterns.md`; for +extension-point mechanics, `docs/reatom-extensions.md`. + +## Read Source First + +| Pattern | The decision | Template (shipped) | Demo (proves it) | +| ----------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------- | +| Form save semantics | What `onSubmit` returns owns the post-save state: a returned payload rebaselines, nothing resets. | `packages/create-karkas/template/src/shared/reatom/forms.ts` | `apps/demo/src/pages/login/model/routes.tsx` | +| Form-level alert gating | An alert shows only failures no field owns; a mapped stale error can never migrate into it. | `formAlertMessage` in `…/shared/reatom/forms.ts` | `apps/demo/src/pages/login/ui/LoginPage.tsx` | +| Visible field errors | The error reads `triggered`, not just `error`, so stale copy leaves when the user fixes the value. | `visibleFieldError` in `…/shared/reatom/forms.ts` | `apps/demo/src/pages/login/ui/LoginPage.tsx` | + +## Rules + +- Every ported pattern lives in the template AND is wired into at least one + real demo page, widget, or story. An export nothing in the demo uses does not + count as shipped. +- The reasoning lives in comments at the decision points in the code. A reader + must be able to tell what breaks if they "simplify" each non-obvious part. +- Porting protocol, candidate status, and per-port deviations are tracked in + `.pi/prompts/migrate-reatom-patterns.candidates.md`. +- Publishing a new pattern entry on the site is part of the port, not an + afterthought: add `site/src/content/patterns/.md` with problem, + decision, file pointers, and a demo link. + +## Workflows + +### Adopting a pattern in a generated project + +1. Find the mechanism in your project's `src/shared/reatom/` (or the layer the + catalog above names) — generated projects ship the same files as the + template. +2. Read the comments at the decision points before using the API; the naive + alternative is usually documented there with the reason it fails. +3. Mirror the demo wiring (`apps/demo` in the karkas repository) for your + equivalent page. + +### Porting a new pattern + +Follow the protocol in +`.pi/prompts/migrate-reatom-patterns.candidates.md`: read the production +source fully, abstract per the methodology, land code + tests + demo wiring + +site entry, run the full quality gate, record status in the candidates file. + +## Edge Cases + +- A pattern may exist in the template before its richest demo exists (for + example `withSavedState` before a settings page ships) — the demo link in the + site entry must still point at wiring that exists, never at a plan. +- If the template and demo copies of a shared file diverge, that is a bug: fix + it before building on either. diff --git a/site/src/content.config.ts b/site/src/content.config.ts index 58f8624e..2cc6404c 100644 --- a/site/src/content.config.ts +++ b/site/src/content.config.ts @@ -1,6 +1,27 @@ import { glob } from "astro/loaders"; import { defineCollection, z } from "astro:content"; +// Solved-problem catalog: mechanisms proven in production and shipped in the +// template, each entry narrating the decision and pointing at the code. +const patterns = defineCollection({ + loader: glob({ pattern: "**/*.md", base: "./src/content/patterns" }), + schema: z.object({ + title: z.string(), + date: z.coerce.date(), + // The failure users would hit without the invention. + problem: z.string(), + // The non-obvious choice the invention encodes, in one sentence. + decision: z.string(), + tag: z.enum(["forms", "state", "api", "ui", "persistence", "routing"]), + // Repo paths where the mechanism lives — template first, demo second. + files: z.array(z.string()).min(1), + // Path under /demo/ where the behavior is visible in the running app. + demo: z.string().optional(), + // Stable sort key so the catalog reads in porting order. + order: z.number(), + }), +}); + // Narrative project updates — the editorial feed described in the brief. // Formal versioned notes live in GitHub Releases; these entries explain // decisions: what changed, the problem it solves, and where to inspect it. @@ -18,4 +39,4 @@ const updates = defineCollection({ }), }); -export const collections = { updates }; +export const collections = { updates, patterns }; diff --git a/site/src/content/patterns/form-save-semantics.md b/site/src/content/patterns/form-save-semantics.md new file mode 100644 index 00000000..51ae5b62 --- /dev/null +++ b/site/src/content/patterns/form-save-semantics.md @@ -0,0 +1,68 @@ +--- +title: Form save semantics +date: 2026-09-07 +tag: forms +problem: >- + A form that just saved still claims there is work left: the submit button stays + primed, the unsaved-changes warning stays up, and an edit typed while the + request was in flight gets adopted as "saved" even though it never reached the + server. Naive post-save handling — reset the form, or reset and re-set the + values — produces exactly these lies. +decision: >- + What `onSubmit` returns decides the post-save state: a returned payload + rebaselines the form (`init`, not `reset`) so in-flight edits survive and stay + dirty, and returning nothing clears the form — one place owns the decision. +files: + - packages/create-karkas/template/src/shared/reatom/forms.ts + - apps/demo/src/shared/reatom/forms.ts + - apps/demo/src/pages/login/model/routes.tsx +demo: /demo/login +order: 1 +--- + +Every app with forms hits the same failure modes, and every team re-solves them +worse under deadline. The Karkas template ships the solved versions as Reatom +form extensions — imported from the shared barrel, attached with `.extend()`, +no configuration. + +## The rebaseline decision + +After a successful save a form must stop reading as dirty without losing what +the user sees. The mechanism is `withSavedState`: the values `onSubmit` +**returns** become the form's new baseline via `init` — not `reset`, which +would clobber anything typed while the request was in flight. Two consequences +fall out of that choice: + +- The payload rebaselined is the payload actually persisted. Rebaseline from + the form's live state instead, and an edit that never reached the server + reads as saved — the edit is silently lost while the UI says otherwise. +- A write-only form (credentials, one-way secrets) returns nothing from + `onSubmit`, and `withSavedState` clears it with `reset`. Nothing to read + back; the secret must not linger. + +`reatomForm`'s own `resetOnSubmit` option is deliberately unused: two owners of +the post-save decision disagree with each other. `withSavedState` is the single +owner. + +## Failures no field can carry + +A network error or a server-side rejection has no field to live under. +`formAlertMessage(form)` decides when a form-level alert may show: never while +a field validation failure explains the same rejection (the alert would print +the same sentence twice), and never for a submit error a caller has mapped +elsewhere (pass an `isHandled` predicate) — mapped errors outlive the field +errors they produced, and the stale text must not migrate into the alert. + +## Errors that leave when the user fixes the value + +`visibleFieldError(field)` reads the field's `triggered` flag alongside its +error. With `keepErrorOnChange: false` Reatom keeps the last issue for +bookkeeping but drops `triggered` — reading only `validation.error` leaves +stale copy on screen while the user fixes the value. + +## Where to see it + +The login form in the demo wires `withFormSubmitHandler` (native-submit +bridging) and `formAlertMessage` (form-level alert) — the smallest complete +usage. The full rebaseline story lands with settings and disclosure-card +patterns. diff --git a/site/src/lib/patterns.ts b/site/src/lib/patterns.ts new file mode 100644 index 00000000..4ec1819c --- /dev/null +++ b/site/src/lib/patterns.ts @@ -0,0 +1,15 @@ +import { getCollection, type CollectionEntry } from "astro:content"; + +/** + * Solved-problem catalog, in porting order. Every published entry names the + * code it ships in; an entry without a repo file pointer cannot pass review. + * + * Dev-only until the section ships: `astro dev` gets the catalog, production + * builds get an empty list, so the landing section hides and entry pages are + * not generated. + */ +export async function getPatterns(): Promise[]> { + if (!import.meta.env.DEV) return []; + const patterns = await getCollection("patterns"); + return patterns.sort((a, b) => a.data.order - b.data.order); +} diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index e431af7b..cbc67544 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -1,9 +1,11 @@ --- import Base from '../layouts/Base.astro' import Frame from '../components/Frame.astro' +import { getPatterns } from '../lib/patterns' import { getUpdates } from '../lib/updates' const latest = (await getUpdates()).slice(0, 3) +const patterns = (await getPatterns()).slice(0, 3) const fmt = (d: Date) => d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) @@ -258,6 +260,44 @@ const explore = [
+ {/* PATTERNS (dev-only: getPatterns returns [] in production builds) --- */} + {patterns.length > 0 && ( +
+
+
+
+ Patterns +

Problems you were about to solve worse.

+

+ Mechanisms proven in production and shipped in the template — each one a decision, not + just code: what stays mounted, what clears when, who owns the state after a save. +

+
+ All patterns → +
+ + +
+
+ )} + +
+ {/* UPDATES ----------------------------------------------------------- */}
@@ -601,6 +641,11 @@ const explore = [ text-transform: uppercase; letter-spacing: 0.08em; } + .feed-demo { + color: var(--steel-deep); + text-decoration: underline; + text-underline-offset: 2px; + } .feed-example { color: var(--steel-deep); border: 1px solid var(--steel); diff --git a/site/src/pages/patterns/[...slug].astro b/site/src/pages/patterns/[...slug].astro new file mode 100644 index 00000000..bbb6a259 --- /dev/null +++ b/site/src/pages/patterns/[...slug].astro @@ -0,0 +1,227 @@ +--- +import { render } from 'astro:content' +import Base from '../../layouts/Base.astro' +import { getPatterns } from '../../lib/patterns' + +export async function getStaticPaths() { + const patterns = await getPatterns() + return patterns.map((entry) => ({ + params: { slug: entry.id }, + props: { entry }, + })) +} + +const { entry } = Astro.props +const { Content } = await render(entry) + +const fmt = (d: Date) => + d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + +const repo = 'https://github.com/apphane-dev/karkas/blob/main' +--- + + +
+
+ ← All patterns + +
+ + +

{entry.data.title}

+

{entry.data.problem}

+

{entry.data.decision}

+ +
+ +
+ +
+ +
+

Where it ships

+
    + { + entry.data.files.map((file) => ( +
  • + {file} + + open ↗ + +
  • + )) + } +
+
+ + { + entry.data.demo && ( + + See it in the demo → + + ) + } +
+
+
+ + + diff --git a/site/src/pages/patterns/index.astro b/site/src/pages/patterns/index.astro new file mode 100644 index 00000000..b3b86d8c --- /dev/null +++ b/site/src/pages/patterns/index.astro @@ -0,0 +1,81 @@ +--- +import Base from '../../layouts/Base.astro' +import { getPatterns } from '../../lib/patterns' + +const patterns = await getPatterns() + +const fmt = (d: Date) => + d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) +--- + + +
+
+ ← Back to Karkas +
+ Patterns +

Problems you were about to solve worse.

+

+ Each entry is a mechanism proven in production and shipped in the template. The point is + never the code alone — it is the decision inside it: what stays mounted, what clears when, + who owns the state after a save. Start a project from the template and the problem is + already solved, with the reasoning attached. +

+
+ + { + patterns.length > 0 ? ( + + ) : ( +

No patterns published yet.

+ ) + } +
+
+ + + From aae9bd6034d352c106d8d908ee6781aa6f746a7e Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Mon, 7 Sep 2026 23:00:14 +0300 Subject: [PATCH 03/27] docs: surface patterns in site nav; drop migration stamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patterns is now in the header nav and footer Explore column, so /patterns is reachable from every page instead of only the landing section. Reader-facing pages and docs state the current design only: no "ported" stamps, no dates — the candidates file in .pi/prompts remains the workflow's status tracker. --- docs/patterns.md | 28 +++++++++---------- site/src/components/Footer.astro | 1 + site/src/components/Header.astro | 1 + site/src/content.config.ts | 1 - .../content/patterns/form-save-semantics.md | 1 - site/src/pages/patterns/[...slug].astro | 4 --- site/src/pages/patterns/index.astro | 4 --- 7 files changed, 16 insertions(+), 24 deletions(-) diff --git a/docs/patterns.md b/docs/patterns.md index 3a33733b..36f06a14 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -5,7 +5,7 @@ This doc follows the source-first approach in `docs/README.md`. ## Overview The template ships solved problems, not just scaffolding: mechanisms proven in -production, ported with the decision that makes them correct. Each pattern is +production, each carrying the decision that makes it correct. Every pattern is demonstrated in the running demo app — code without a demo is a snippet, and snippets rot. Long-form narratives live on the site (`site/src/content/patterns/`); this doc is the terse index. The comments at the decision points in the source @@ -16,23 +16,23 @@ extension-point mechanics, `docs/reatom-extensions.md`. ## Read Source First -| Pattern | The decision | Template (shipped) | Demo (proves it) | -| ----------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------- | -| Form save semantics | What `onSubmit` returns owns the post-save state: a returned payload rebaselines, nothing resets. | `packages/create-karkas/template/src/shared/reatom/forms.ts` | `apps/demo/src/pages/login/model/routes.tsx` | -| Form-level alert gating | An alert shows only failures no field owns; a mapped stale error can never migrate into it. | `formAlertMessage` in `…/shared/reatom/forms.ts` | `apps/demo/src/pages/login/ui/LoginPage.tsx` | +| Pattern | The decision | Template (shipped) | Demo (proves it) | +| ----------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------- | +| Form save semantics | What `onSubmit` returns owns the post-save state: a returned payload rebaselines, nothing resets. | `packages/create-karkas/template/src/shared/reatom/forms.ts` | `apps/demo/src/pages/login/model/routes.tsx` | +| Form-level alert gating | An alert shows only failures no field owns; a mapped stale error can never migrate into it. | `formAlertMessage` in `…/shared/reatom/forms.ts` | `apps/demo/src/pages/login/ui/LoginPage.tsx` | | Visible field errors | The error reads `triggered`, not just `error`, so stale copy leaves when the user fixes the value. | `visibleFieldError` in `…/shared/reatom/forms.ts` | `apps/demo/src/pages/login/ui/LoginPage.tsx` | ## Rules -- Every ported pattern lives in the template AND is wired into at least one - real demo page, widget, or story. An export nothing in the demo uses does not - count as shipped. +- Every pattern in this catalog lives in the template AND is wired into at + least one real demo page, widget, or story. An export nothing in the demo + uses does not count as shipped. - The reasoning lives in comments at the decision points in the code. A reader must be able to tell what breaks if they "simplify" each non-obvious part. -- Porting protocol, candidate status, and per-port deviations are tracked in +- Candidate evaluation and the production-source workflow are tracked in `.pi/prompts/migrate-reatom-patterns.candidates.md`. -- Publishing a new pattern entry on the site is part of the port, not an - afterthought: add `site/src/content/patterns/.md` with problem, +- Publishing a new pattern entry on the site is part of shipping the pattern, + not an afterthought: add `site/src/content/patterns/.md` with problem, decision, file pointers, and a demo link. ## Workflows @@ -47,7 +47,7 @@ extension-point mechanics, `docs/reatom-extensions.md`. 3. Mirror the demo wiring (`apps/demo` in the karkas repository) for your equivalent page. -### Porting a new pattern +### Adding a new pattern Follow the protocol in `.pi/prompts/migrate-reatom-patterns.candidates.md`: read the production @@ -57,7 +57,7 @@ site entry, run the full quality gate, record status in the candidates file. ## Edge Cases - A pattern may exist in the template before its richest demo exists (for - example `withSavedState` before a settings page ships) — the demo link in the - site entry must still point at wiring that exists, never at a plan. + example `withSavedState` before a settings page ships) — the demo link in + the site entry must still point at wiring that exists, never at a plan. - If the template and demo copies of a shared file diverge, that is a bug: fix it before building on either. diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index 6e6c5788..60b8a7fc 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -5,6 +5,7 @@ const cols = [ links: [ { label: 'Demo app', href: '/demo/' }, { label: 'Storybook', href: '/storybook/' }, + { label: 'Patterns', href: '/patterns' }, { label: 'Updates', href: '/updates' }, ], }, diff --git a/site/src/components/Header.astro b/site/src/components/Header.astro index a693343f..c9820c65 100644 --- a/site/src/components/Header.astro +++ b/site/src/components/Header.astro @@ -5,6 +5,7 @@ const nav = [ { label: 'Stack', href: '/#stack' }, { label: 'Testing', href: '/#testing' }, { label: 'Workflow', href: '/#workflow' }, + { label: 'Patterns', href: '/patterns' }, { label: 'Updates', href: '/updates' }, ] --- diff --git a/site/src/content.config.ts b/site/src/content.config.ts index 2cc6404c..4d533935 100644 --- a/site/src/content.config.ts +++ b/site/src/content.config.ts @@ -7,7 +7,6 @@ const patterns = defineCollection({ loader: glob({ pattern: "**/*.md", base: "./src/content/patterns" }), schema: z.object({ title: z.string(), - date: z.coerce.date(), // The failure users would hit without the invention. problem: z.string(), // The non-obvious choice the invention encodes, in one sentence. diff --git a/site/src/content/patterns/form-save-semantics.md b/site/src/content/patterns/form-save-semantics.md index 51ae5b62..d4c5616c 100644 --- a/site/src/content/patterns/form-save-semantics.md +++ b/site/src/content/patterns/form-save-semantics.md @@ -1,6 +1,5 @@ --- title: Form save semantics -date: 2026-09-07 tag: forms problem: >- A form that just saved still claims there is work left: the submit button stays diff --git a/site/src/pages/patterns/[...slug].astro b/site/src/pages/patterns/[...slug].astro index bbb6a259..d825d5bf 100644 --- a/site/src/pages/patterns/[...slug].astro +++ b/site/src/pages/patterns/[...slug].astro @@ -14,9 +14,6 @@ export async function getStaticPaths() { const { entry } = Astro.props const { Content } = await render(entry) -const fmt = (d: Date) => - d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) - const repo = 'https://github.com/apphane-dev/karkas/blob/main' --- @@ -28,7 +25,6 @@ const repo = 'https://github.com/apphane-dev/karkas/blob/main'

{entry.data.title}

diff --git a/site/src/pages/patterns/index.astro b/site/src/pages/patterns/index.astro index b3b86d8c..20986a3f 100644 --- a/site/src/pages/patterns/index.astro +++ b/site/src/pages/patterns/index.astro @@ -3,9 +3,6 @@ import Base from '../../layouts/Base.astro' import { getPatterns } from '../../lib/patterns' const patterns = await getPatterns() - -const fmt = (d: Date) => - d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) ---
  • {p.data.tag} -
    {p.data.title} From 71973f343efdbd7b6a1dc252aa12f0a975f6429f Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Mon, 7 Sep 2026 23:02:44 +0300 Subject: [PATCH 04/27] docs: widen pattern page; add demo how-to steps The pattern detail page now uses 980px instead of the 720px reading measure so tables and code breathe. Each entry explains how to see the behavior on the demo page: the first entry walks through breaking the login password to surface the form-level alert (and noting the fields stay clean, which is the alert-gating decision), then signing in to see the bridged submit. The docs rule now requires that how-to section in every entry. --- docs/patterns.md | 3 ++- .../content/patterns/form-save-semantics.md | 23 +++++++++++++++---- site/src/pages/patterns/[...slug].astro | 2 +- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/patterns.md b/docs/patterns.md index 36f06a14..edc1897e 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -33,7 +33,8 @@ extension-point mechanics, `docs/reatom-extensions.md`. `.pi/prompts/migrate-reatom-patterns.candidates.md`. - Publishing a new pattern entry on the site is part of shipping the pattern, not an afterthought: add `site/src/content/patterns/.md` with problem, - decision, file pointers, and a demo link. + decision, file pointers, a demo link, and a "See it in the demo" section + with step-by-step instructions for reproducing the behavior. ## Workflows diff --git a/site/src/content/patterns/form-save-semantics.md b/site/src/content/patterns/form-save-semantics.md index d4c5616c..5e9d57f0 100644 --- a/site/src/content/patterns/form-save-semantics.md +++ b/site/src/content/patterns/form-save-semantics.md @@ -59,9 +59,22 @@ error. With `keepErrorOnChange: false` Reatom keeps the last issue for bookkeeping but drops `triggered` — reading only `validation.error` leaves stale copy on screen while the user fixes the value. -## Where to see it +## See it in the demo -The login form in the demo wires `withFormSubmitHandler` (native-submit -bridging) and `formAlertMessage` (form-level alert) — the smallest complete -usage. The full rebaseline story lands with settings and disclosure-card -patterns. +The login form wires `withFormSubmitHandler` (native-submit bridging) and +`formAlertMessage` (form-level alert) — the smallest complete usage. To see +the behavior: + +1. Open the demo and go to the login page (the "See it in the demo" button + below leads straight there). +2. Leave the prefilled email, break the password, submit. A form-level alert + appears under the heading — that is `formAlertMessage` showing a failure + no field owns. Note what does _not_ happen: no error text appears under + the email or password fields, because neither field owns this failure. +3. Restore the password (`password`) and submit. The button shows its pending + state, then the dashboard replaces the page — `withFormSubmitHandler` + bridged the native form submission, and the session atom's route guard let + you through. + +The rebaseline half of the story (`withSavedState`) shows its value on +settings-style forms and lands with the disclosure-card pattern. diff --git a/site/src/pages/patterns/[...slug].astro b/site/src/pages/patterns/[...slug].astro index d825d5bf..9e8adb84 100644 --- a/site/src/pages/patterns/[...slug].astro +++ b/site/src/pages/patterns/[...slug].astro @@ -67,7 +67,7 @@ const repo = 'https://github.com/apphane-dev/karkas/blob/main'