open-design/craft
Mohamed Abdallah d80402a8ca
craft: add form-validation so generated forms aren't stuck in 2018 RHF/Formik patterns (#625)
* feat(craft): add form-validation + opt-ins on saas-landing, mobile-onboarding

Module 5 of 5 in the behavioral craft series proposed in #501.
Modules 1-4 merged: state-coverage (#502), animation-discipline (#515),
accessibility-baseline (#587), rtl-and-bidi (#595).

Picks up where accessibility-baseline.md ends (label + describedby +
invalid + role=alert for inline errors) and connects the four layers a
real form spans: WHATWG Constraint Validation as the platform floor,
validation timing as a state machine on the input, WCAG 3.3.x as the
announcement and recovery contract, schema as the cross-stack truth.

Sections: input state machine; validation timing (4 rules anchored on
:user-invalid Baseline 2023); Constraint Validation API rules
(setCustomValidity, requestSubmit vs submit, readonly + #11841,
inputmode); error wiring beyond the baseline (adaptive messages,
error summary without role=alert, preserve user input on error);
schema as cross-stack contract (Standard Schema, server-authoritative,
Zod 4 z.email() form); WCAG 3.3.3 / 3.3.4 / 3.3.8 / 3.3.9; native
mobile parity (UIKit, SwiftUI, Compose, Flutter, RN); common mistakes.

Reviewed in 3 loops with Claude CLI Opus 4.7 xhigh effort:
- Loop 1: 6 P0s caught (SwiftUI Form validity claim, SwiftUI
  announcement primitive, Compose semantics syntax, UIKit
  UIAlertController, contradictory Baymard stats, 3.3.8 CAPTCHA
  framing reversed) + 11 P1/P2s; all addressed.
- Loop 2: verified P0 fixes; flagged 1 P1 (RN table row scrambled) +
  4 P2s; all addressed.
- Loop 3: SHIP verdict. Three P2 nits applied (Zod 4 z.email() form,
  WebAIM Million 2026 stat woven in: 51% page-level, 33.1% input-level).

WebAIM Million 2026 numbers verified directly against
webaim.org/projects/million/.

Skill opt-ins: saas-landing (lead capture form), mobile-onboarding
(sign-in screen). Skill bodies do not contain validation-specific
instructions that would override craft guidance — opt-in alone is
sufficient. README updated.

Refs #501.

* fix(craft+skills): form-validation review fixes (lefarcen + mrcfps P2s)

Both non-blocking findings addressed:

- Drop form-validation from saas-landing.craft.requires. The skill body
  produces a CTA-driven landing page with no JS and no interactive
  form. Adding form-validation injected ~221 lines of irrelevant prompt
  pressure and conflicted with the README opt-in rule ("primary
  artifact contains an interactive form"). mobile-onboarding keeps the
  opt-in — sign-in screen is a real form.
- Reword timing rule 4 (async checks). Previous "never block submit on
  a network round-trip" was too broad and conflicted with the
  schema-layer "server is the truth" rule. Split into two paths:
  background preflight (uniqueness, address lookup) doesn't gate the
  form; authoritative submit-path server validation must await the
  server response and surface its field errors. The rule is "don't let
  a slow background check freeze the form," not "don't ever wait for
  the server."

* fix(craft): form-validation mrcfps round-2 (novalidate trade-off, Flutter RTL)

Two non-blocking precision items:

- novalidate trade-off: previous wording said keeping required/pattern/type
  preserves no-JS PE, but a literal server-rendered <form novalidate>
  disables the browser's submit-blocking and validation UI even when
  JS is unavailable — losing the no-JS constraint-validation floor.
  Reworded to spell out the two safe patterns: (A) render <form>
  without novalidate server-side and have the form library set
  form.noValidate = true after hydration, or (B) ship novalidate from
  the start only when the submit path reaches server validation
  without JS. Either way, keep the constraint attributes.
- Flutter announcement example: hardcoded TextDirection.ltr would
  announce Arabic/Hebrew/Persian validation messages with wrong bidi
  direction when this craft is combined with rtl-and-bidi. Switched
  to SemanticsService.announce(message, Directionality.of(context))
  with an explicit warning never to hardcode the direction.

* fix(craft): form-validation mrcfps round-3 (readonly safety, Compose error message)

Two non-blocking precision items:

- Non-input readonly fallback: previous text said `aria-readonly` plus
  hidden mirror input was an option for non-input controls that need
  to submit. But `aria-readonly` doesn't actually stop a `<select>` or
  custom widget from being changed, so the visible control can drift
  while the hidden input ships a stale value — user sees one option,
  server gets another. Tightened: prefer `disabled` plus a same-named
  hidden input, or non-editable text plus hidden input. If using
  `aria-readonly`, the interaction must also be blocked or the two
  values kept in sync.
- Compose error message: previous rule was too absolute about avoiding
  `Modifier.semantics { error("…") }`. `isError = true` flips the
  field state but does not carry the localized error message; Android
  Compose accessibility guidance pairs `isError` with
  `semantics { error(message) }` so the accessibility service gets the
  real text. The trap is duplication, not the API itself. Reframed
  the rule: use both, source the message from the same state field as
  `supportingText` so they stay in sync.

* fix(craft): form-validation Compose live-region API name

Compose row in the native-mobile parity table named a "LiveRegion"
semantic that doesn't exist. Real API is `Modifier.semantics
{ liveRegion = LiveRegionMode.Polite }` on the supporting-text node.
Also replaced the generic `view.announceForAccessibility(…)` with the
Compose-idiomatic `LocalView.current.announceForAccessibility(message)`
so generated snippets compile.
2026-05-06 20:09:30 +08:00
..
accessibility-baseline.md feat(craft): accessibility-baseline module + opt-ins on dashboard, hr-onboarding, mobile-onboarding (#587) 2026-05-06 09:18:59 +08:00
animation-discipline.md feat(craft): animation-discipline module + opt-ins on mobile-app, mobile-onboarding, gamified-app (#515) 2026-05-05 18:32:30 +08:00
anti-ai-slop.md feat(craft): add brand-agnostic craft references + Refero-derived lint rules (#225) 2026-05-02 11:00:33 +08:00
color.md feat(craft): add brand-agnostic craft references + Refero-derived lint rules (#225) 2026-05-02 11:00:33 +08:00
form-validation.md craft: add form-validation so generated forms aren't stuck in 2018 RHF/Formik patterns (#625) 2026-05-06 20:09:30 +08:00
README.md craft: add form-validation so generated forms aren't stuck in 2018 RHF/Formik patterns (#625) 2026-05-06 20:09:30 +08:00
rtl-and-bidi.md craft: add rtl-and-bidi so OD artifacts don't break for Arabic / Hebrew / Persian users (#595) 2026-05-06 12:43:48 +08:00
state-coverage.md feat(craft): state-coverage module + opt-ins on dashboard, mobile-app, kanban-board (#502) 2026-05-05 16:31:05 +08:00
typography.md feat(craft): add brand-agnostic craft references + Refero-derived lint rules (#225) 2026-05-02 11:00:33 +08:00

Craft references

Brand-agnostic craft knowledge. Each file is a small, dense rulebook on one dimension of professional UI craft (typography, color, motion, …). Skills opt into the references they need; the daemon injects only the requested ones into the system prompt above the active skill body.

Why a third axis next to skills/ and design-systems/

Axis Scope Example
skills/ Artifact shape saas-landing, dashboard, pricing-page
design-systems/ Brand visual language (the 9-section DESIGN.md) linear-app, apple, notion
craft/ Universal craft knowledge — true regardless of brand letter-spacing rules, accent-overuse caps, anti-AI-slop

DESIGN.md tells the agent which colors and fonts a brand uses. craft/ tells the agent the universal rules a competent designer applies on top — e.g. ALL CAPS always needs ≥0.06em tracking, regardless of the brand.

How a skill opts in

Add an od.craft.requires array to the skill's front-matter. Only the listed sections are injected, so a skill that needs only typography pays no token cost for color/motion content.

od:
  craft:
    requires: [typography, color, anti-ai-slop]

Allowed values match the file names in this directory minus the .md extension. Unknown values are silently ignored (forward-compatible).

Why silent fallback instead of fail-fast?

A skeptical reader will ask: "If a skill requests a planned-but-not-yet-vendored section and the corresponding file doesn't exist yet, shouldn't we warn the user?" We chose forward-compatibility over fail-fast: a skill authored today can list a planned slug and start benefiting the moment the matching craft/<slug>.md is vendored in a follow-up PR, with no skill edit needed. The cost of a missed reference is a missing paragraph in the system prompt, not a broken skill — so the loud failure mode is not worth the friction.

Note for skill authors arriving from older guidance: an earlier draft used motion as the future-slug placeholder. The shipped equivalent today is animation-discipline. Use that one if your skill emits motion.

Enforcement levels

Craft files mix auto-checked rules and guidance.

  • Auto-checked. Rules wired into apps/daemon/src/lint-artifact.ts — currently the P0 list in anti-ai-slop.md (Tailwind-indigo accent, two-stop hero gradients, emoji-as-icons, etc.). The linter reports these as findings back to the UI (for P0/P1 badges) and to the agent (as a system reminder for self-correction). Artifact persistence is not currently hard-blocked on P0 hits.
  • Guidance. The rest. The agent reads the rules, reviewers apply them, the linter doesn't check them.

A purely behavioral craft file (state-coverage, animation-discipline) is guidance unless a specific rule is later promoted into lint-artifact.ts.

Files

File Section name When to require
typography.md typography Any skill that emits typed content (~all skills)
color.md color Any skill that emits styled output (~all skills)
anti-ai-slop.md anti-ai-slop Marketing pages, landing pages, decks
state-coverage.md state-coverage Any skill with stateful UI (dashboards, mobile apps, forms, list/table views)
animation-discipline.md animation-discipline Any skill that ships motion: mobile apps, multi-screen flows, gamified UI, transitions, microinteractions
accessibility-baseline.md accessibility-baseline Any skill that ships interactive UI: dashboards, forms, mobile flows, anything with focus/labels/keyboard paths
rtl-and-bidi.md rtl-and-bidi Any skill that ships localized text or layout: blogs, docs, financial tables, mobile apps, anything that may render Arabic / Hebrew / Persian
form-validation.md form-validation Any skill whose primary artifact contains an interactive form: lead capture, sign-in, signup, settings, multi-step intake

Partial-stateful skills. A skill that's mostly static but contains an embedded form, data table, or query surface should opt in. State-coverage rules apply to the stateful component, not the whole page.

More sections (icons, craft-details) will be added in follow-up PRs as we wire the linter side.

Attribution

Craft content is adapted from the MIT-licensed refero_skill project (© Refero Design), with edits to fit Open Design's house style and link back to OD's design tokens (var(--accent) etc.) instead of generic Tailwind hex values.