Initial import: open-design source for helix-mind.ai distribution
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
This repository contains the open-design daemon CLI source code, built and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use by the HelixMind /design slash command. Licenses: Apache-2.0 (root) + MIT (skills/*)
This commit is contained in:
84
craft/README.md
Normal file
84
craft/README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 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.
|
||||
|
||||
```yaml
|
||||
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](https://github.com/referodesign/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.
|
||||
201
craft/accessibility-baseline.md
Normal file
201
craft/accessibility-baseline.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Accessibility baseline craft rules
|
||||
|
||||
Universal rules for the legal floor of accessibility plus the craft
|
||||
commitments that go beyond it. The active `DESIGN.md` decides brand
|
||||
appearance; this file decides which rules an artifact has to clear
|
||||
before it ships.
|
||||
|
||||
> Grounded in primary sources: WCAG 2.2 Understanding pages,
|
||||
> ISO/IEC 40500:2025, ADA Title II 2024 + 2026 IFR, EN 301 549 v3.2.1,
|
||||
> WAI-ARIA 1.3 + AccName 1.2 + Core AAM 1.2, WebAIM Million 2026
|
||||
> (February 2026 crawl), A11yn (arXiv 2510.13914), APCA W3C silver
|
||||
> branch.
|
||||
|
||||
## Prior art and scope
|
||||
|
||||
Existing OSS a11y guidance for AI agents (`fecarrico/A11Y.md`,
|
||||
`awesome-copilot agents/accessibility.agent.md`,
|
||||
`Community-Access/accessibility-agents`) tends to inline a checklist of
|
||||
WCAG SCs without versioning the legal floor or specifying which
|
||||
constraints survive on iOS / Android / Flutter. This file scopes
|
||||
narrower: the compliance floor an OD artifact must clear, with
|
||||
jurisdiction notes and native-mobile parity. Heuristic rules and
|
||||
linter-checked items live in sibling craft files
|
||||
(`anti-ai-slop.md`, `state-coverage.md`); WCAG SC numbers map to
|
||||
specific rules below rather than being re-listed.
|
||||
|
||||
## The legal floor changes by jurisdiction
|
||||
|
||||
- **EU (EAA, enforcement live 2025-06-28):** EN 301 549 v3.2.1 is the OJ-cited harmonised standard; it references **WCAG 2.1 AA**. EN 301 549 v4.1.1 (which incorporates WCAG 2.2's nine new SCs) is OJ-citation-targeted late 2026 / 2027. Until then, EAA references WCAG 2.1. The Web Accessibility Directive (WAD, EU 2016/2102) covers public-sector bodies separately and also points at EN 301 549.
|
||||
- **US public sector — ADA Title II 2024 final rule:** **WCAG 2.1 AA**. The 2026-04-20 IFR slipped deadlines: 2027-04-26 for jurisdictions with population ≥ 50,000; 2028-04-26 for sub-50,000 and special districts.
|
||||
- **US federal procurement — Section 508 (Revised 508 Standards):** harmonised with EN 301 549 → references **WCAG 2.0 AA** in the current published rev. The Access Board has WCAG 2.x updates in flight; until they ship, federal IT procurement floor is WCAG 2.0.
|
||||
- **US private sector — ADA Title III:** no federal regulation specifies a technical standard. Settlements and DOJ guidance routinely cite **WCAG 2.1 AA** as the de-facto target, but the legal mechanism is case-by-case, not rule-based.
|
||||
- **ISO/IEC 40500:2025** (October 2025) ratified WCAG 2.2 verbatim. Does not by itself change EU or US legal floors.
|
||||
|
||||
**Practical rule for craft:** target **WCAG 2.2 AA** as the working
|
||||
ceiling. It clears the WCAG 2.1 AA legal floor in both jurisdictions
|
||||
and prepares for v4.1.1. Anything below 2.2 AA is craft debt.
|
||||
|
||||
## Color contrast
|
||||
|
||||
| Pair | WCAG 2.x AA minimum |
|
||||
|---|---|
|
||||
| Normal text below 18 pt regular / 14 pt bold (covers most body and UI text) | 4.5:1 |
|
||||
| Large text (≥18 *pt* regular ≈24 px, or ≥14 *pt* bold ≈18.5 px) | 3:1 |
|
||||
| Non-text UI components and graphical objects | 3:1 |
|
||||
| Focus indicator vs adjacent and unfocused state | 3:1 |
|
||||
|
||||
Thresholds are **inclusive** — exactly 4.5:1 or 3:1 passes. Don't round
|
||||
up: 2.999:1 fails because rounding is not a permitted mechanism.
|
||||
|
||||
"Large text" means **18 pt** regular, not 18 px. 18 px regular needs
|
||||
4.5:1; 14 pt bold (≈18.5 px) qualifies for 3:1, 14 px bold does not.
|
||||
|
||||
**APCA as a parallel design check.** APCA's Lc value catches font-weight
|
||||
and stem-thickness effects that WCAG 2.x luminance ratios miss. Body
|
||||
copy at Lc ≥60 is a reasonable parallel pass; APCA's actual lookup
|
||||
table is size- and weight-dependent (heavier weights at larger sizes
|
||||
clear at lower Lc, thin small text needs Lc ≥75+). APCA is not part
|
||||
of WCAG, EN 301 549, ADA, or Section 508 compliance as of 2026-05 —
|
||||
keep WCAG 2.2 AA as the compliance floor and treat APCA as
|
||||
design-review only. If you ship APCA tooling, use the `apca-w3`
|
||||
package; the SAPC repo is non-commercial.
|
||||
|
||||
## Touch targets
|
||||
|
||||
| Bar | SC | Size |
|
||||
|---|---|---|
|
||||
| AA (legal floor) | 2.5.8 Target Size (Minimum) | **24×24 CSS px** |
|
||||
| AAA (craft commitment) | 2.5.5 Target Size (Enhanced) | 44×44 CSS px |
|
||||
| iOS HIG | — | 44×44 pt |
|
||||
| Material 3 | — | 48×48 dp |
|
||||
|
||||
WCAG 2.5.8 lists five exceptions where the 24×24 minimum doesn't
|
||||
apply: **Spacing** (a 24-CSS-px exclusion circle around the target
|
||||
doesn't intersect adjacent ones), **Equivalent** (an alternative
|
||||
control of sufficient size achieves the same function), **Inline**
|
||||
(target sits inside a sentence, e.g. links in body copy), **User
|
||||
agent control** (browser default like a native scrollbar), and
|
||||
**Essential** (the smaller size is required to convey information,
|
||||
e.g. a map pin). The Spacing exception is the one icon-button
|
||||
toolbars rely on; the others are narrower than they read and
|
||||
shouldn't be used to justify undersized primary actions.
|
||||
|
||||
## Focus visibility
|
||||
|
||||
Removing the focus outline via CSS is a **triple failure**: 1.4.11
|
||||
Non-text Contrast, 2.4.7 Focus Visible, and 2.4.13 Focus Appearance
|
||||
(AAA). Use `:focus-visible` for keyboard users; suppress the outline
|
||||
for mouse clicks only when an alternative non-color affordance exists.
|
||||
|
||||
For AAA (2.4.13): indicator area must equal at least a 2 CSS px
|
||||
perimeter of the component, contrast ≥3:1 between focused and
|
||||
unfocused states. A 1-px outline at 3:1 doesn't qualify.
|
||||
|
||||
## Form input labels
|
||||
|
||||
WebAIM Million 2026 (which uses WAVE, not axe-core): **51% of top 1M
|
||||
home pages have at least one missing form-input label; 33.1% of all
|
||||
6.9M inputs are unlabeled**. The page-level rate moved from 48.2%
|
||||
(2025) to 51% (2026) — missing-label prevalence is one of the few
|
||||
categories WebAIM explicitly calls out as rising in 2026, against an
|
||||
overall errors-per-page count of 56.1.
|
||||
|
||||
Default form-error wiring (WCAG 2.2 + ARIA APG):
|
||||
|
||||
```html
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" required
|
||||
aria-describedby="email-hint email-error"
|
||||
aria-invalid="true">
|
||||
<span id="email-hint">Used for receipts only.</span>
|
||||
<span id="email-error" role="alert">Email must include @ and a domain.</span>
|
||||
```
|
||||
|
||||
`aria-describedby` is the production default; `aria-errormessage` has
|
||||
incomplete screen-reader support as of 2026-05 (full on NVDA, partial
|
||||
on JAWS / VoiceOver / TalkBack) — treat as progressive enhancement.
|
||||
|
||||
WCAG 3.3.7 Redundant Entry is **Level A** (legal floor). Re-asking for
|
||||
data the user already entered "in the same process" fails unless the
|
||||
site auto-populates or offers a selectable shortcut. Browser autofill
|
||||
does not satisfy it.
|
||||
|
||||
## Keyboard operability and semantic structure
|
||||
|
||||
Visual contrast and labelled inputs don't matter if a keyboard or
|
||||
screen-reader user can't reach the control or parse the page. The
|
||||
bullets below are Level A / AA WCAG essentials plus a small set of
|
||||
structural conventions OD treats as craft commitments. WCAG levels
|
||||
are noted per item.
|
||||
|
||||
- **Tab reachability** (2.1.1 Keyboard, Level A): every interactive element must be reachable and operable via keyboard. `tabindex="-1"` removes from the tab order; `tabindex` values >0 break document order and should not be used. (2.1.3 No Exception extends 2.1.1 to AAA by removing the underlying-function exception.)
|
||||
- **Activation keys** (2.1.1, Level A): `<button>` activates on Enter and Space; `<a href="…">` activates on Enter. A bare `<a>` without `href` is not a link, not focusable, and not keyboard-operable — use `<a href="…">` for navigation or `<button>` for actions, never a placeholder anchor. Custom controls must implement the matching key handlers and `role`.
|
||||
- **No keyboard trap** (2.1.2, Level A): focus must be able to leave any component via the same standard keys it entered with. Modal dialogs are a focus-trap *by design*, not a violation — they trap until dismissed by Escape or the close button.
|
||||
- **Focus order** (2.4.3, Level A): tab order must follow the meaningful reading order. Don't rely on positive `tabindex` to fix DOM that's out of order; fix the DOM.
|
||||
- **Native control first** (craft convention, anchored on 4.1.2 Name/Role/Value Level A): a `<button>` is keyboard-operable, focusable, name-resolvable, and announced as a button by every AT for free. `<div role="button" tabindex="0">` requires you to re-implement all of that and most reimplementations miss `aria-pressed`, disabled state, or Space-on-keyup. Reach for ARIA only when no native element fits.
|
||||
- **Document language** (3.1.1, Level A): `<html lang="...">` is required. Sub-tree language switches use `lang` on the inner element.
|
||||
- **Heading hierarchy** (1.3.1 Info and Relationships Level A; 2.4.6 Headings and Labels Level AA): WCAG requires programmatically-determined structure and descriptive headings, not a specific outline shape. OD craft convention layers on: prefer one `<h1>` per page and don't skip levels (`<h1>` → `<h3>` without `<h2>`). Visual size and heading level are independent.
|
||||
- **Landmarks** (1.3.1, 2.4.1 Bypass Blocks Level A): use `<header>` `<nav>` `<main>` `<aside>` `<footer>` rather than `<div role="banner">` etc. AT users navigate by landmark; a page with no landmarks is a wall of divs.
|
||||
- **Text alternatives** (1.1.1 Non-text Content, Level A): `<img alt="...">` for content images, `alt=""` for decorative; `aria-label` on icon-only buttons; long-form description for charts and SVG data viz. A chart without a text alternative is unreadable to a screen reader.
|
||||
|
||||
## ARIA discipline
|
||||
|
||||
WebAIM Million 2026 shows ARIA pages average **59.1 errors** vs
|
||||
**42** on non-ARIA pages — about 17 extra errors on the ARIA side.
|
||||
The gap was 30 in 2025 (57 vs 27) and 15 in 2024; YoY direction is
|
||||
noisy, but ARIA usage is up (82.7% of home pages in 2026 vs 79.4% in
|
||||
2025) while correctness lags. ARIA deployment outpaces ARIA
|
||||
correctness.
|
||||
|
||||
Decision order, per ARIA APG:
|
||||
|
||||
1. Native HTML element with the right semantics.
|
||||
2. Native element under custom visuals if restyling is required.
|
||||
3. APG pattern verbatim if neither fits.
|
||||
4. Closest APG pattern + documented deviation. Last resort.
|
||||
|
||||
Never invent ARIA.
|
||||
|
||||
## Reduced motion and flashing
|
||||
|
||||
See `animation-discipline.md` for the full rule set. The non-negotiable
|
||||
that anchors here: WCAG 2.3.1 (Level A) — flashing more than three
|
||||
times per one-second period is non-conformant unless the flash area
|
||||
stays below the general and red flash thresholds. Photosensitive
|
||||
epilepsy is the protected concern.
|
||||
|
||||
## Native mobile parity
|
||||
|
||||
Web ARIA does not auto-translate. Each platform has its own labelling API.
|
||||
|
||||
| Platform | Label | Role |
|
||||
|---|---|---|
|
||||
| iOS UIKit | `accessibilityLabel` | `accessibilityTraits` |
|
||||
| iOS SwiftUI | `.accessibilityLabel(…)` | `.accessibilityAddTraits(.isButton)` |
|
||||
| Android Compose | `Modifier.semantics { contentDescription = … }` | `Modifier.semantics { role = Role.Button }` |
|
||||
| Flutter | `Semantics(label: …)` | `Semantics(button: true, …)` |
|
||||
| React Native | `accessibilityLabel` | `accessibilityRole` |
|
||||
|
||||
Use the platform API for each target. AI-generated mobile UI that
|
||||
mirrors web ARIA verbatim usually misses the platform-native screen
|
||||
reader path.
|
||||
|
||||
## Common mistakes (lint these)
|
||||
|
||||
- "Target Size 44×44" cited as the AA bar. 44×44 is **AAA** (2.5.5). AA is **24×24** (2.5.8).
|
||||
- "18 px = large text" — wrong. Threshold is 18 *pt* regular (~24 px) or 14 pt bold (~18.5 px).
|
||||
- "EAA = WCAG 2.2 AA" — wrong. EN 301 549 v3.2.1 is anchored to WCAG 2.1.
|
||||
- "Section 508 = WCAG 2.1 AA" — wrong as of 2026-05. Revised 508 still references WCAG 2.0 AA; the Access Board update is in flight, not shipped.
|
||||
- "Tabindex fixes focus order" — `tabindex` >0 reorders against DOM and almost always makes it worse. Fix the DOM.
|
||||
- "Modal traps focus → keyboard trap" — confusing 2.1.2. A modal trapping focus until Escape / close is correct behaviour, not a violation.
|
||||
- "Heading size = heading level" — visual hierarchy and `<h1>`/`<h2>`/`<h3>` are independent. Style the level you mean.
|
||||
- "WebAIM Million uses axe-core" — uses WAVE.
|
||||
- "WCAG 3 will use APCA" — APCA was dropped from WCAG 3 in July 2023.
|
||||
- "Adding ARIA improves accessibility" — empirically the opposite. WebAIM Million 2026: ARIA pages average 59.1 errors, non-ARIA pages 42.
|
||||
- "Bare `<a>` with click handler is a link" — wrong. `<a>` without `href` is not focusable, not keyboard-operable, and not a link. Use `<a href="…">` for navigation, `<button>` for actions.
|
||||
- Removing the focus outline via `outline: none` without a replacement. Triple failure: 1.4.11, 2.4.7, 2.4.13.
|
||||
- Placeholder text as the only label for a form input. Fails 1.3.1 and 3.3.2; placeholder disappears on input.
|
||||
- Using `aria-description` as the sole state-carrier on `role="row"`. JAWS 2025/2026 silently drops it ([FreedomScientific standards-support #927](https://github.com/FreedomScientific/standards-support/issues/927)).
|
||||
- Native HTML `<button>` reimplemented as `<div role="button">` without keyboard handling, focus, or `aria-pressed`.
|
||||
- A11y treated as web-only. Flutter / iOS / Android have their own labelling APIs that web ARIA doesn't reach.
|
||||
154
craft/animation-discipline.md
Normal file
154
craft/animation-discipline.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Animation discipline craft rules
|
||||
|
||||
Universal rules for when motion earns its place in a UI and what numbers
|
||||
constrain it. The active `DESIGN.md` decides brand-specific motion
|
||||
personality; this file decides whether motion should run at all and at
|
||||
what duration, easing, and accessibility floor.
|
||||
|
||||
> Grounded in primary sources: Tversky/Morrison/Bétrancourt 2002
|
||||
> (IJHCS), Heer & Robertson TVCG 2007, Harrison/Yeo/Hudson CHI 2010,
|
||||
> Doherty & Thadani IBM Systems Journal 1982, Chang & Ungar UIST 1993,
|
||||
> Material 3 motion tokens, IBM `@carbon/motion`, Apple SwiftUI
|
||||
> Animation API, W3C View Transitions, WCAG 2.2.2 + 2.3.3, WebKit's
|
||||
> 2017 `prefers-reduced-motion` rationale.
|
||||
|
||||
## When motion earns its place
|
||||
|
||||
Tversky/Morrison/Bétrancourt's 2002 meta-analysis (IJHCS 57, pp. 247-262)
|
||||
found that every study claiming animation aids comprehension had a
|
||||
broken control — the static version had less information, different
|
||||
procedures, or hidden interactivity. When equalised, animation does
|
||||
**not** beat static for teaching complex systems. The single use case
|
||||
the paper endorses is real-time spatial or temporal reorientation:
|
||||
page transitions, container morphs, viewpoint changes, progress
|
||||
indicators (p. 257).
|
||||
|
||||
A follow-on hazard: Palmiter & Elkerton found animation-trained users
|
||||
*declined* one week after training, while text-trained users *improved*
|
||||
(Tversky 2002, p. 255). Animation's apparent short-term parity hides
|
||||
worse retention.
|
||||
|
||||
So animate when the user is moving through space, time, or state —
|
||||
navigation, container expansion, progress feedback, gesture
|
||||
follow-through. Don't animate to teach, decorate, signal "premium",
|
||||
or fill silence.
|
||||
|
||||
## Duration thresholds
|
||||
|
||||
The cross-design-system convergence is **150 ms** — Material 3 `short3`,
|
||||
IBM Carbon `moderate-01`, Shopify Polaris `150`, Tailwind default,
|
||||
SLDS `duration-fast` all land here. Use it as the default duration for
|
||||
state-confirmation feedback.
|
||||
|
||||
| Duration | Use |
|
||||
|---|---|
|
||||
| 50–100 ms | Instant feedback (button press, toggle commit, hover) |
|
||||
| 150 ms | Default for state-confirmation |
|
||||
| 200–300 ms | Entering UI (modals, sheets, dropdowns) |
|
||||
| 300–500 ms | Cross-screen transitions, container morphs |
|
||||
| > 500 ms | Reserved for cross-screen, staged, or platform-native transitions (e.g. M3 `long2`-`extraLong4`, Heer & Robertson 2007's per-stage recommendation). |
|
||||
|
||||
Non-navigation microinteractions — hover, press, toggle, validation,
|
||||
chip selection, row expansion — should stay under 500 ms. Past that the
|
||||
user notices the motion as motion and waits on the UI rather than
|
||||
working through it. Two qualifications: frequent animations (a hover
|
||||
effect seen 50 times per session) need to stay ≤200 ms; mobile
|
||||
animations should run 20–30% shorter than desktop equivalents because
|
||||
travel distances are shorter.
|
||||
|
||||
## Curve vs spring
|
||||
|
||||
Use a curve for opacity, color, and any property that changes value
|
||||
between two known points. Use a spring for position, scale, rotation,
|
||||
and gesture-driven motion — anything that should feel physical.
|
||||
|
||||
Material 3 standard easing is `cubic-bezier(0.2, 0, 0, 1)` — front-loaded;
|
||||
the trailing zero makes the curve hit its target instantly and settle.
|
||||
M2 standard was the symmetric `cubic-bezier(0.4, 0, 0.2, 1)`, preserved
|
||||
in M3 under the name `legacy`. Anyone shipping the M2 curve and calling
|
||||
it "M3" is on legacy tokens. M3 `emphasized` is a **two-segment Bézier
|
||||
path**, not a single cubic-bezier; single-cubic approximations silently
|
||||
lose the front-loaded character. CSS `linear()` (Chrome 113+) is the
|
||||
only way to replicate it on a single property.
|
||||
|
||||
Apple's published SwiftUI default spring is
|
||||
`(response: 0.5, dampingFraction: 0.825, blendDuration: 0)`. The widely
|
||||
cited `.snappy = 0.25 s, .smooth = 0.35 s` numbers are wrong — Apple's
|
||||
docs assign all three presets a 0.5 s base, differing only in bounce
|
||||
(0 / 0.15 / 0.3).
|
||||
|
||||
Spring framework defaults disagree. motion.dev's physics-mode default
|
||||
is ζ ≈ 0.5 (bouncy). React Spring's `default` is ζ = 0.997 (critically
|
||||
damped). Same word "default", opposite feel — React Spring's `wobbly`
|
||||
is the actual feel-equivalent of motion.dev's `default`. Pick
|
||||
consciously.
|
||||
|
||||
## Reduced motion
|
||||
|
||||
Every animation that translates, scales, rotates, or parallaxes must
|
||||
respect `@media (prefers-reduced-motion: reduce)`. WebKit shipped this
|
||||
in 2017 to address vestibular triggers; the W3C MQ5 spec lets the UA
|
||||
or author **strip motion entirely or substitute static imagery** —
|
||||
the spec does not mandate which.
|
||||
|
||||
Working rule: strip motion-on-an-axis (translate, scale, rotate,
|
||||
parallax). Keep opacity/color crossfades as substitutes when a state
|
||||
change still needs to be conveyed. Be explicit — the View Transitions
|
||||
API does **not** apply `prefers-reduced-motion` automatically; the
|
||||
author must add a query override on the pseudo-elements or skip
|
||||
`startViewTransition` entirely.
|
||||
|
||||
WCAG calibration: 2.2.2 (Pause/Stop/Hide) is Level A — the legal floor
|
||||
under ADA Title II 2024 / EN 301 549 / EAA — but it names cognitive,
|
||||
attentional, and reading populations, not vestibular. Vestibular
|
||||
language lives in 2.3.3, which is **AAA**. Don't conflate the two.
|
||||
Building for vestibular users is a craft commitment beyond the legal
|
||||
floor, not a WCAG mandate.
|
||||
|
||||
**Flashing limits.** WCAG 2.3.1 (Level A) permits flashing only when
|
||||
there are no more than three flashes within any one-second period, or
|
||||
the flashing area stays below the general and red flash thresholds.
|
||||
WCAG 2.3.2 (AAA) forbids flashing more than three times within any
|
||||
one-second period, regardless of area or brightness. The protected
|
||||
concern is photosensitive epilepsy; the legal floor isn't negotiable. For gamified UI, onboarding celebrations, sparkles,
|
||||
confetti, level-up bursts, and shimmer: avoid rapid flashing unless
|
||||
tested against the thresholds, and prefer one-shot animations over
|
||||
loops.
|
||||
|
||||
## Repeated and ambient motion
|
||||
|
||||
The rules above target one-shot transitions. Looping motion (skeleton
|
||||
shimmer, idle backgrounds, autoplay, reward bursts) has different
|
||||
constraints.
|
||||
|
||||
- Cap iteration count: carousels at 3-5 cycles then pause; skeleton shimmer until content lands, never indefinitely.
|
||||
- WCAG 2.2.2 (Level A) requires a pause control for any motion running longer than 5 seconds — moving, blinking, or scrolling content, not only video.
|
||||
- Cancel ambient motion on route change.
|
||||
- Reward animations are one-shot. Confetti, sparkles, level-up bursts fire once and dismiss; no looping timer.
|
||||
- Spinners must not run indefinitely. Escalate to progress/cancel states and stop animation at 60 s, matching `state-coverage.md`.
|
||||
|
||||
## Cross-platform handoff
|
||||
|
||||
Native conventions diverge.
|
||||
|
||||
- **iOS** uses spring physics with perceptual `(response, dampingFraction)` parameters. Apple HIG documents principles, not numerical curves; the SwiftUI Animation API JSON is the source for actual numbers. UIView curve cubic-beziers commonly cited online are reverse-engineered, not Apple-published.
|
||||
- **Android** uses cubic-bezier curves through M3 motion tokens (50–1000 ms range, 16 named durations). Predictive back is a *gesture-progress primitive*, not a transition primitive — `BackEvent.progress` is sampled per-frame from the touch stream and the destination is rendered behind the current surface while still on it. Cancellation is a first-class lifecycle state.
|
||||
- **Web** has the View Transitions API (default 0.25 s, no easing specified by the spec — falls through to CSS `ease`). Same-document support 90.94%; cross-document 87.82%. Cross-document is same-origin and user-initiated only.
|
||||
|
||||
A "one curve fits all platforms" approach loses on each. If the brief
|
||||
specifies platform fidelity, follow the platform; if it specifies brand
|
||||
consistency, pick one motion vocabulary and apply it everywhere.
|
||||
|
||||
## Common mistakes (lint these)
|
||||
|
||||
- "Skeleton screens feel 11% faster" — Harrison/Yeo/Hudson CHI 2010 measured *backwards-decelerating ribbed determinate progress bars* (n=16). The induced-motion mechanism doesn't transfer to skeletons.
|
||||
- "Heer & Robertson recommend 300–1000 ms eased transitions" — they tested 1.25 s and 2 s only. Their recommendation is "~1 second per stage".
|
||||
- "Doherty Threshold = 400 ms" — the 1982 paper does not contain "400". The lowest threshold actually measured is 300 ms.
|
||||
- M2 standard easing `cubic-bezier(0.4, 0, 0.2, 1)` labelled as "Material 3". M3's standard is `cubic-bezier(0.2, 0, 0, 1)`.
|
||||
- Animations that *perform* a state change rather than *confirming* one that has already happened. Optimistic UI first; motion second.
|
||||
- More than 500 ms on any non-cross-screen transition.
|
||||
- Animation as the only signal of state change. Reduced-motion users miss it; always pair with a static affordance (color, position, label).
|
||||
- Ignoring `prefers-reduced-motion` on transform-based animations — the highest-cost vestibular triggers.
|
||||
- Curve-based animation on a `transform: scale()` that should feel physical. Use a spring.
|
||||
- Hero choreography in productivity tools. Motion budget belongs inside the product on functional micro-feedback, not on landing-page sequences.
|
||||
- Decorative motion in the working canvas of a productivity tool.
|
||||
84
craft/anti-ai-slop.md
Normal file
84
craft/anti-ai-slop.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Anti-AI-slop rules
|
||||
|
||||
Concrete, checkable rules that distinguish "designed by a human who has
|
||||
shipped product" from "default LLM output." Several rules below are
|
||||
auto-enforced by the daemon's `lint-artifact` linter — failing an
|
||||
enforced rule is not a style preference, it is a regression. The
|
||||
rest are guidance for agents and reviewers and are flagged inline as
|
||||
"(guidance, not auto-checked)" so the contract with the linter stays
|
||||
honest.
|
||||
|
||||
> Adapted from [refero_skill](https://github.com/referodesign/refero_skill)
|
||||
> (MIT), tightened to match Open Design's lint surface.
|
||||
|
||||
## The seven cardinal sins
|
||||
|
||||
These are the patterns the linter blocks at P0 (must-fix):
|
||||
|
||||
1. **Default Tailwind indigo as accent** — exactly `#6366f1`, `#4f46e5`,
|
||||
`#4338ca`, `#3730a3`, `#8b5cf6`, `#7c3aed`, `#a855f7`. The active
|
||||
`DESIGN.md` provides `--accent`; use it. Indigo is the textbook AI
|
||||
tell. (The daemon's `lint-artifact` flags any of these as a solid
|
||||
accent; keep this list in sync with `AI_DEFAULT_INDIGO` in
|
||||
`apps/daemon/src/lint-artifact.ts`.)
|
||||
2. **Two-stop "trust" gradient on the hero** — purple→blue, blue→cyan,
|
||||
indigo→pink. A flat surface + intentional type beats this every
|
||||
time.
|
||||
3. **Emoji as feature icons** — `✨`, `🚀`, `🎯`, `⚡`, `🔥`, `💡`
|
||||
inside `<h*>`, `<button>`, `<li>`, or `class*="icon"`. Use
|
||||
1.6–1.8px-stroke monoline SVG with `currentColor`.
|
||||
4. **Sans-serif on display text when the seed binds a serif** — h1/h2
|
||||
must use `var(--font-display)`, not a hardcoded Inter / Roboto /
|
||||
`system-ui`.
|
||||
5. **Rounded card with a colored left-border accent** — the canonical
|
||||
"AI dashboard tile" shape. Drop either the radius or the left
|
||||
border.
|
||||
6. **Invented metrics** — "10× faster", "99.9% uptime", "3× more
|
||||
productive". Either pull from a real source or use a labelled
|
||||
placeholder.
|
||||
7. **Filler copy** — `lorem ipsum`, `feature one / two / three`,
|
||||
`placeholder text`, `sample content`. An empty section is a design
|
||||
problem to solve with composition, not by inventing words.
|
||||
|
||||
## Soft tells (P1 — should fix)
|
||||
|
||||
- **Standard "Hero → Features → Pricing → FAQ → CTA" sequence with no
|
||||
variation** *(guidance, not auto-checked)*. This is the AI-template
|
||||
skeleton; introduce at least one unconventional section (testimonial
|
||||
wall as full-bleed quote, pricing as comparison-against-status-quo,
|
||||
an inline mini-product-demo).
|
||||
- **External placeholder image CDNs** (`unsplash.com`, `placehold.co`,
|
||||
`placekitten.com`, `picsum.photos`). Fragile and obvious. Use the
|
||||
shipped `.ph-img` placeholder class.
|
||||
- **More than ~12 raw hex values outside `:root`.** Tokens were not
|
||||
honoured.
|
||||
- **`var(--accent)` used 6+ times in the rendered body.** Cap at 2
|
||||
visible uses per screen.
|
||||
|
||||
## Polish tells (P2 — nice to fix)
|
||||
|
||||
- **Sections without `data-od-id`** — comment mode can't target them.
|
||||
- **Decorative blob / wave SVG backgrounds** *(guidance, not
|
||||
auto-checked)* — meaningless geometry.
|
||||
- **Perfect symmetric layout with no visual tension** *(guidance, not
|
||||
auto-checked)* — alternating density (one tight section, one
|
||||
breathing section) reads as intentional.
|
||||
|
||||
## How to add soul without breaking the rules
|
||||
|
||||
Aim for **~80% proven patterns + ~20% distinctive choice**. The 20%
|
||||
should live in:
|
||||
|
||||
- One bold visual move — a typography choice, a single color decision,
|
||||
an unexpected proportion.
|
||||
- Voice and microcopy — a button that says "Start tracking" beats one
|
||||
that says "Get started".
|
||||
- One micro-interaction the user will remember — a button press that
|
||||
moves 2px, a number that counts up.
|
||||
- One detail that could only have been put there by someone who used
|
||||
the product (a subtle kbd shortcut hint, a status badge with
|
||||
product-specific phrasing).
|
||||
|
||||
If a reviewer screenshots the artifact and someone outside the project
|
||||
can identify which product it's from — you have soul. If not, you
|
||||
shipped a template.
|
||||
88
craft/color.md
Normal file
88
craft/color.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Color craft rules
|
||||
|
||||
Universal color rules applied on top of the active `DESIGN.md`. The
|
||||
design system supplies the palette tokens; this file enforces how to
|
||||
*use* them.
|
||||
|
||||
> Adapted from [refero_skill](https://github.com/referodesign/refero_skill)
|
||||
> (MIT). All examples reference Open Design's standard tokens
|
||||
> (`--bg`, `--surface`, `--fg`, `--muted`, `--border`, `--accent`).
|
||||
|
||||
## Palette structure
|
||||
|
||||
A coherent palette has four layers. Plan all four before writing any CSS.
|
||||
|
||||
| Layer | Share of pixels | Tokens |
|
||||
|---|---|---|
|
||||
| **Neutrals** | 70–90% | `--bg`, `--surface`, `--fg`, `--muted`, `--border` |
|
||||
| **Accent** (one) | 5–10% | `--accent` only — never invent a second accent |
|
||||
| **Semantic** | 0–5% | `--success`, `--warn`, `--danger` |
|
||||
| **Effect** | <1% | gradients, glows; rarely justified |
|
||||
|
||||
## Accent discipline
|
||||
|
||||
The single biggest readability failure in AI-generated UIs is accent
|
||||
overuse. Hard caps:
|
||||
|
||||
- **At most 2 visible uses of `--accent` per screen.** Typical pair:
|
||||
one eyebrow / chip + one primary CTA. Or one accent card + one tab
|
||||
pill. Pick a pair, not a flood.
|
||||
- Links count as accent; demote to `--fg` underline if you also have a
|
||||
CTA on the same screen.
|
||||
- Hover/focus rings count as accent. Ration accordingly.
|
||||
|
||||
## Contrast minimums
|
||||
|
||||
Run these as gates, not goals:
|
||||
|
||||
| Pair | Minimum |
|
||||
|---|---|
|
||||
| Body text (≤16 px) on background | **4.5:1** |
|
||||
| Large text (>18 px or 14 px bold) | **3:1** |
|
||||
| UI components against adjacent surfaces | **3:1** |
|
||||
|
||||
When the brand color clashes (low-contrast indigo on light background is
|
||||
common), darken the accent to a `600`-level shade for text use; reserve
|
||||
the brand-bright variant for fills only.
|
||||
|
||||
## Dark themes
|
||||
|
||||
Avoid pure black and pure white — both cause vibration and eye strain.
|
||||
|
||||
| Token | Dark theme | Light theme |
|
||||
|---|---|---|
|
||||
| Background | `#0f0f0f` (not `#000`) | `#fafafa` (not `#fff`) |
|
||||
| Foreground | `#f0f0f0` (not `#fff`) | `#111111` (not `#000`) |
|
||||
|
||||
On dark surfaces, prefer **semi-transparent white borders** over solid
|
||||
dark borders — a 1px `rgba(255,255,255,0.08)` reads as structure
|
||||
without adding visual noise.
|
||||
|
||||
## Semantic color naming
|
||||
|
||||
Always name tokens by **purpose**, never by hue:
|
||||
|
||||
```css
|
||||
/* good */
|
||||
--accent: #2f6feb;
|
||||
--success: #17a34a;
|
||||
|
||||
/* bad — locks you out of theming */
|
||||
--blue-500: #2f6feb;
|
||||
--green-500: #17a34a;
|
||||
```
|
||||
|
||||
## Anti-defaults
|
||||
|
||||
- **Indigo `#6366f1`** (Tailwind `indigo-500`) is the most reliable
|
||||
AI-slop tell. The active `DESIGN.md` provides `--accent`; use it. If
|
||||
the brief truly needs indigo, make the user say so explicitly. If
|
||||
your `DESIGN.md` encodes indigo as `--accent`, that is intentional —
|
||||
the linter only flags hardcoded hex, so `var(--accent)` uses are
|
||||
unaffected even when the resolved color happens to be `#6366f1`.
|
||||
- **Two-stop "trust" gradient** (purple → blue, blue → cyan, etc.) on a
|
||||
hero is the second most reliable tell. A flat surface + one
|
||||
type-driven hierarchy beats it every time.
|
||||
- **Decorative gradients with no functional purpose**. Gradients should
|
||||
separate hierarchies (header → body, primary CTA → secondary), not
|
||||
decorate empty space.
|
||||
221
craft/form-validation.md
Normal file
221
craft/form-validation.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Form validation craft rules
|
||||
|
||||
Universal rules for form validation lifecycle, error wiring beyond the
|
||||
accessibility baseline, and the schema-as-contract layer that makes
|
||||
the same validation work on the server and the client. The active
|
||||
`DESIGN.md` decides how the field looks; this file decides *when* the
|
||||
field tells the user it's wrong, *how* the error reaches assistive
|
||||
tech, and *where* the rule lives.
|
||||
|
||||
> Grounded in primary sources: WHATWG HTML Living Standard
|
||||
> (Constraint Validation section under "Form control infrastructure"),
|
||||
> CSS Selectors L4 (`:user-invalid`), WCAG 2.2 SC 3.3.x
|
||||
> Understanding pages, ARIA APG forms patterns, Standard Schema spec
|
||||
> (`@standard-schema/spec`), Baymard 2024 inline-validation research
|
||||
> checkout-UX benchmark, WebAIM Million 2026 forms findings.
|
||||
|
||||
## Prior art and scope
|
||||
|
||||
Existing OSS forms guidance for AI agents pins to one layer at a time
|
||||
— `szilu/ux-designer-skill` is UX-opinion grade with no spec anchors,
|
||||
`Community-Access/accessibility-agents/forms-specialist` is
|
||||
WCAG-anchored but AT-only and doesn't reach the platform validity
|
||||
layer or the schema contract. This file 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.** A11y wiring lives next door in `accessibility-baseline.md`
|
||||
(label + describedby + invalid + `role="alert"` for inline errors);
|
||||
this file picks up where that ends.
|
||||
|
||||
## The input state machine
|
||||
|
||||
Every input passes through these states. The names trace back to RHF /
|
||||
Formik vocabulary on web; the *shape* applies regardless of stack.
|
||||
Drive error chrome off the state, not off raw `:invalid` or
|
||||
focus/blur booleans.
|
||||
|
||||
| State | Meaning | UI |
|
||||
|---|---|---|
|
||||
| `pristine` | User has not interacted | No error chrome, no green check |
|
||||
| `dirty` | User has typed but not committed (still focused) | No error chrome yet |
|
||||
| `touched` | User has blurred at least once after editing | Field-level constraint runs |
|
||||
| `invalid-after-touched` | Constraint failed after blur | Show error, link via `aria-describedby` |
|
||||
| `invalid-after-submit` | Submit attempted, field still invalid | Same plus focus management to summary or first invalid field |
|
||||
| `recovering` | User editing an already-invalid field | Re-validate on `input`, not on next blur |
|
||||
| `submitting` | Action in flight | Disable submit, announce status via a polite live region |
|
||||
| `server-error` | Server returned an error for this field | Use server's message text; treat as `invalid-after-submit` |
|
||||
|
||||
Decision rule that collapses validation-timing debates: errors appear
|
||||
on transition into `invalid-after-touched`, clear on transition out
|
||||
of any invalid state, and never appear from `pristine` or plain
|
||||
`dirty`. CSS `:user-invalid` matches the `invalid-after-touched` /
|
||||
`invalid-after-submit` states for free.
|
||||
|
||||
## Validation timing
|
||||
|
||||
Baymard's checkout-UX benchmark (2024-01-09 inline-validation article):
|
||||
**31% of sites have no inline validation, and most of the rest fire
|
||||
too early.** The participant quote that anchors the research: *"Why
|
||||
are you telling me my email address is wrong, I haven't had a chance
|
||||
to fill it all out yet?"* Premature firing is the loudest UX failure
|
||||
in this space.
|
||||
|
||||
The four rules:
|
||||
|
||||
1. **First blur after edit** runs the field-level constraint. Not on focus, not on first keystroke, not on every keystroke.
|
||||
2. **Once a field is invalid, switch to `input`-event re-validation** so the error clears the moment input becomes valid. Don't make the user blur again to dismiss it.
|
||||
3. **On submit**, run the schema parse. Move focus to the error summary at the top of the form (a heading-led container with `tabindex="-1"`, no `role="alert"` — see the wiring section), or to the first invalid field if no summary exists. Don't move focus on every keystroke.
|
||||
4. **Async checks** split into two paths. *Background preflight* (uniqueness while typing, address lookup) debounces 250-500 ms, announces via a polite live region, and never gates typing or keeps the submit button disabled indefinitely. *Authoritative server validation on submit* is different: the submit path must await the server's response and surface field errors from it, since the server is the truth. Don't conflate the two — the rule is "don't let a slow background check freeze the form," not "don't ever wait for the server."
|
||||
|
||||
CSS gets you most of timing rule 1 for free: style off `:user-invalid`
|
||||
not `:invalid`. The `:user-invalid` selector is Baseline Newly
|
||||
available 2023 (Chrome 119, Firefox 88, Safari 16.5; Firefox shipped
|
||||
the prefixed `:-moz-ui-invalid` years earlier and unprefixed in v88)
|
||||
and matches only after the user has either submitted the form or
|
||||
blurred the field with bad input.
|
||||
|
||||
## Constraint Validation API as the platform floor
|
||||
|
||||
Native HTML constraints are not an alternative to JS validation; they
|
||||
are the substrate the rest of the layers run on. They survive JS
|
||||
failure, they integrate with autofill, and they are what
|
||||
`reportValidity()` and screen-reader native announcements key off.
|
||||
|
||||
```html
|
||||
<input type="email" name="email" required>
|
||||
```
|
||||
|
||||
Use these declaratively for every field that has them: `required`,
|
||||
`type` (email, url, number, tel), `pattern`, `min`/`max`,
|
||||
`minlength`/`maxlength`, `step`. Cross-field rules and dynamic
|
||||
constraints go through `setCustomValidity()` on both `input` and
|
||||
`change` events — autofill flows historically fired one without the
|
||||
other on some browsers, so listening on both is the cheap defense.
|
||||
|
||||
Rules of the API:
|
||||
|
||||
- **Empty string clears `setCustomValidity`.** Not `null`, not no-arg.
|
||||
- **`form.requestSubmit()` honors validation; `form.submit()` skips it.** Never call the second.
|
||||
- `disabled` controls are barred from validation and not submitted. The HTML spec says `readonly` is also barred, but `readonly` only has defined behavior on `<input>` and `<textarea>` — implementations diverge for `<select readonly>` and `<button readonly>` ([whatwg/html#11841](https://github.com/whatwg/html/issues/11841)). For non-input controls where the value must still submit, the safe pattern is `disabled` plus a same-named hidden `<input>` carrying the value, or rendering the non-editable text alongside a hidden `<input>`. `aria-readonly` alone is not enough — a `<select>` or custom widget tagged `aria-readonly="true"` is still interactable, so the visible control can drift while the hidden input ships a stale or different value. If you do use `aria-readonly`, you must also block the interaction or keep both values in sync.
|
||||
- `inputmode` is a virtual-keyboard hint, **not** validation. `<input type="text" inputmode="numeric" pattern="[0-9]*">` is the Baymard-recommended shape for ZIPs / OTPs / card numbers; `pattern="[0-9]*"` is the historical iOS-Safari trigger for the numeric keypad on top of `inputmode`. `type="number"` adds spinners, strips leading zeros, applies locale-decimal handling, and varies field width across browsers — wrong for any of these.
|
||||
|
||||
## Error wiring beyond the baseline
|
||||
|
||||
The default error pattern in `accessibility-baseline.md` (`<label>` +
|
||||
`aria-describedby` + `aria-invalid` + `role="alert"`) covers WCAG
|
||||
3.3.1 / 3.3.2. Three additions matter for real forms:
|
||||
|
||||
**Adaptive error messages.** Baymard 2023: 98% of audited sites use
|
||||
generic catch-all errors ("Provide a valid phone number") rather than
|
||||
the specific subrule that fired ("Phone number is too short"). The
|
||||
back end already knows the subrule; surfacing it cuts re-submit
|
||||
attempts. Ship 4-7 distinct messages per high-traffic complex field
|
||||
(email, phone, card, postal code). The scale of the problem matches
|
||||
WebAIM Million 2026: missing form-input labels appear on **51% of
|
||||
the top 1M home pages** (input-level rate **33.1%** of all 6.9M
|
||||
inputs sampled) — labels and error messages are the categories
|
||||
trending sideways or worse year-over-year while overall a11y errors
|
||||
drop.
|
||||
|
||||
**Error summary at the top, on submit only.** Long forms benefit from
|
||||
a summary list of in-page anchor links to invalid fields, focused on
|
||||
submit:
|
||||
|
||||
```html
|
||||
<div id="form-errors" tabindex="-1">
|
||||
<h2>2 problems</h2>
|
||||
<ul>
|
||||
<li><a href="#email">Email is required</a></li>
|
||||
<li><a href="#dob">Date of birth must be in the past</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
The container is heading-led with `tabindex="-1"` so JS can move
|
||||
focus to it on submit (render the summary into the DOM, *then*
|
||||
`.focus()` it; a `hidden` element can't take focus). It does **not**
|
||||
carry `role="alert"` because combining a moved-focus target with an
|
||||
alert role causes double-announcement: alert fires on insertion,
|
||||
focus fires the accessible name + role. Reserve `role="alert"` for
|
||||
inline per-field errors that appear without focus moving — that's
|
||||
the canonical baseline pattern in `accessibility-baseline.md`. WCAG
|
||||
technique G139 covers the summary; not required, high-value for long
|
||||
forms.
|
||||
|
||||
**Preserve user input on error.** Baymard 2024: 34% of audited
|
||||
checkouts wipe the credit-card field when an unrelated error reloads
|
||||
the page. Direct cause of abandonment. Either field-level-validate
|
||||
non-sensitive fields first, or split the payment step. PCI-wise,
|
||||
persisting card values across an error reload is fine via tokenized
|
||||
hosted iframes; never store raw PAN in your own session.
|
||||
|
||||
## Schema as the cross-stack contract
|
||||
|
||||
Validation expressed once, consumed everywhere. The 2026 React shape
|
||||
— `useActionState` + Server Actions + Conform (which added Standard
|
||||
Schema support during the v1.x line) + a Zod 4 / Valibot / ArkType
|
||||
schema — is the most-cited concrete instance: one schema,
|
||||
server-authoritative, validator hot-swappable via the `~standard`
|
||||
interface. The same architecture works in TanStack Form, oRPC, Hono
|
||||
validator middleware, Nuxt UForm, and any other consumer that reads
|
||||
`~standard`.
|
||||
|
||||
```ts
|
||||
const Signup = z.object({
|
||||
email: z.email(), // Zod 4 top-level form
|
||||
password: z.string().min(12),
|
||||
});
|
||||
// Same schema parses on the Server Action and on the Conform client.
|
||||
```
|
||||
|
||||
Three rules that survive across stacks:
|
||||
|
||||
- **Server is the truth, client is the optimization.** Same schema runs in both. Returning `{ errors }` from the action (not throwing) is what feeds back into `useActionState`'s state slot — throwing routes to the Error Boundary and loses the form data.
|
||||
- **Standard Schema is the contract, not Zod.** A form library that ships per-validator resolver shims (`zodResolver`, `valibotResolver`, etc.) is yesterday's stack. Accept any `~standard`-compliant validator.
|
||||
- **`novalidate` on `<form>` does not mean "skip validation".** It means "let the form library repaint errors instead of the browser's bubble." But the trade-off is real: a literal server-rendered `<form novalidate>` disables the browser's submit-blocking and native validation UI **even when JS is unavailable**, which loses the no-JS constraint-validation floor. Pick one of two patterns. **A:** render `<form>` without `novalidate` server-side and have the form library set `form.noValidate = true` after hydration — the no-JS user keeps the browser's native validation, the JS user gets the library's chrome. **B:** ship `novalidate` from the start only when the submit path reaches server validation without JS (Server Action, classic POST handler) so the no-JS user is still protected by the server. Either way, keep `required` / `pattern` / `type` attributes — they survive JS failure and integrate with autofill. (HTML attribute is lowercase `novalidate`; the IDL property on the form element is `noValidate`.)
|
||||
|
||||
## WCAG 3.3.x beyond Error Identification
|
||||
|
||||
`accessibility-baseline.md` covers 3.3.1 (Error ID), 3.3.2 (Labels),
|
||||
and 3.3.7 (Redundant Entry). The rest of 3.3 binds harder on
|
||||
transactional forms:
|
||||
|
||||
- **3.3.3 Error Suggestion (AA):** when the fix is determinable, suggest it in text. Adaptive errors satisfy this. "Date must be MM/DD/YYYY. You entered 5-3-26. Did you mean 05/03/2026?"
|
||||
- **3.3.4 Error Prevention — Legal, Financial, Data (AA):** for any submission with legal / financial / data-modifying consequence, provide one of: reversibility, server-side check + correction step, or a confirm-summary screen before commit.
|
||||
- **3.3.8 Accessible Authentication (AA, WCAG 2.2):** auth steps must not require a cognitive function test (remember a password, transcribe a code, recognize images) without an alternative. CAPTCHAs are the canonical thing this SC restricts; only object-recognition or personal-content variants escape via the narrow exceptions, and not all CAPTCHAs do. Practical floor: never block paste on password / verification-code fields, support password managers, accept verification-code paste from a clipboard.
|
||||
- **3.3.9 Accessible Authentication, No Exception (AAA):** removes even the object-recognition / personal-content exceptions. Aspirational; flag if a project commits to it.
|
||||
|
||||
## Native mobile parity
|
||||
|
||||
Web validation primitives don't auto-translate. Each platform has its
|
||||
own validity machinery and its own AT path. Skills that emit web-only
|
||||
artifacts can skim this section; it's the entry point for skills
|
||||
that ship to mobile (mobile-onboarding, mobile-app, etc.).
|
||||
|
||||
| Platform | Validity primitive | Error announcement |
|
||||
|---|---|---|
|
||||
| iOS UIKit | Hand-rolled state on the view controller; `UITextField` doesn't carry a built-in invalid flag | `UIAccessibility.post(notification: .announcement, argument: "Email is required")` |
|
||||
| iOS SwiftUI | `TextField` + `@State`-driven validation; no built-in `Form`-level validity API as of iOS 18 | `AccessibilityNotification.Announcement("…").post()` (iOS 17+) |
|
||||
| Android Compose | `OutlinedTextField(isError = true, supportingText = { Text("…") })` — `isError` wires the AT error semantic for you | `Modifier.semantics { liveRegion = LiveRegionMode.Polite }` on the supporting-text node, or `LocalView.current.announceForAccessibility(message)` |
|
||||
| Flutter | `TextFormField(validator: (v) => …)` inside a `Form`, `formKey.currentState!.validate()` | `SemanticsService.announce(message, Directionality.of(context))` — never hardcode `TextDirection.ltr`; pull ambient direction so Arabic / Hebrew / Persian flows announce correctly |
|
||||
| React Native | Hand-rolled per field; no platform validity flag | `accessibilityLiveRegion="polite"` on the error node (Android) + `AccessibilityInfo.announceForAccessibility(...)` (iOS) |
|
||||
|
||||
Two parity rules that catch most AI-generated mobile forms:
|
||||
|
||||
- **Use the platform's native validation flag — and pair it with the platform's error-message semantic where one exists.** On Compose, `isError = true` is the right boolean state for the field visuals and AT error-state cue, but it does *not* carry the localized error message. Pair it with `Modifier.semantics { error(message) }` so accessibility services get the actual text — the same string you render in `supportingText`. The trap is duplication: a hand-rolled `Modifier.semantics { error("Email is required") }` next to a different supporting-text string desyncs. Source `error()` from the same state field as `supportingText` so they stay in sync.
|
||||
- **Don't mirror web ARIA into mobile semantics.** `aria-describedby` on a SwiftUI `TextField` is a no-op. Use the platform announcement primitive (`AccessibilityNotification.Announcement` on SwiftUI, `UIAccessibility.post` on UIKit, `announceForAccessibility` on Android, `SemanticsService.announce` on Flutter) for state-change events that need to reach the screen reader.
|
||||
|
||||
## Common mistakes (lint these)
|
||||
|
||||
- Styling off `input:invalid` instead of `input:user-invalid`. Red borders on page load is the loudest "this validation was added without testing" signal.
|
||||
- Validating on every keystroke. Hostile; fires before the user has finished typing.
|
||||
- Generic catch-all error messages ("Invalid input") when the back end already knows which subrule fired. Baymard 2023 found 98% of audited sites do this — the most-cited preventable validation failure in their corpus.
|
||||
- Throwing from a Server Action on validation failure. Routes to the Error Boundary and loses the form data. Return `{ errors }` instead.
|
||||
- `role="alert"` on the error-summary container that focus moves to. Double-announces. Reserve `role="alert"` for inline per-field errors that appear without focus moving.
|
||||
- `aria-busy="true"` on the submit button while submitting. `aria-busy` is for stale containers; for buttons use `disabled` plus a polite live-region status message.
|
||||
- Email-confirm fields ("retype your email"). 3.3.7 redundant entry — exceptions are essential / security / no-longer-valid, not "we want to catch typos." Allow paste and validate the single field instead.
|
||||
- Per-validator resolver shims (`zodResolver`, `valibotResolver`) on a 2026 stack. Accept Standard Schema's `~standard` interface and the validator becomes swappable.
|
||||
- Wiping the credit-card field when an unrelated field errors. Baymard 2024: 34% of audited e-commerce sites; direct abandonment cause.
|
||||
- `setCustomValidity(null)` to clear an error. Pass empty string; `null` does not clear.
|
||||
- Mirroring web ARIA onto SwiftUI / Compose / Flutter. Each platform has its own validity API; `aria-*` attributes don't reach the mobile AT path.
|
||||
186
craft/rtl-and-bidi.md
Normal file
186
craft/rtl-and-bidi.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# RTL and bidirectional craft rules
|
||||
|
||||
Universal rules for right-to-left layout and bidirectional text. The
|
||||
active `DESIGN.md` decides brand visual language; this file decides
|
||||
how that language behaves when the script reads from the right or
|
||||
mixes direction within a line.
|
||||
|
||||
> Grounded in primary sources: Unicode UAX #9 revision 51 (Sept 2025)
|
||||
> + Unicode 17.0, CSS Logical Properties Level 1, HTML Living Standard
|
||||
> (`dir`, `<bdi>`), Tailwind v4.0/v4.2 changelogs, W3C alreq,
|
||||
> Material 3 RTL guidance, Apple HIG internationalization.
|
||||
|
||||
## Base direction and language
|
||||
|
||||
Every full-page RTL artifact needs `<html dir="rtl" lang="ar">` (or
|
||||
the matching `lang` for Hebrew, Persian, Urdu). The `lang` attribute
|
||||
drives font-stack selection, hyphenation, locale-aware speech
|
||||
synthesis, and search-engine indexing — `dir` alone isn't enough.
|
||||
Three patterns cover the common cases:
|
||||
|
||||
- **Full-page RTL.** `<html dir="rtl" lang="ar">`. Everything inside inherits.
|
||||
- **Mixed-language subtree.** Nest `<section dir="ltr" lang="en">…</section>` (or vice versa) when an embedded block uses a different script. Code samples, English citations, foreign brand names.
|
||||
- **User-generated content of unknown direction.** `dir="auto"` on the paragraph. The browser resolves direction from the first strong directional character in the run.
|
||||
|
||||
Setting `lang` without `dir` is fine **at the document root in a
|
||||
default-LTR page** — English doesn't need `dir="ltr"` there because
|
||||
the bidi base direction is already LTR. Inside any opposite-direction
|
||||
ancestor, `lang` does not reset the inherited base direction, so set
|
||||
both `lang` and `dir` on the subtree (`<section dir="ltr" lang="en">`).
|
||||
Setting `dir` without `lang` is rarely correct — at minimum drop the
|
||||
appropriate ISO-639 tag in.
|
||||
|
||||
## Logical properties first
|
||||
|
||||
Hardcoded `left` / `right` is a bug for any layout that might render
|
||||
RTL. Use logical properties on the inline axis. Use them on the block
|
||||
axis when the writing-mode varies; physical otherwise.
|
||||
|
||||
| Logical | LTR resolves to | RTL resolves to |
|
||||
|---|---|---|
|
||||
| `margin-inline-start` / `padding-inline-start` / `inset-inline-start` | left | right |
|
||||
| `margin-inline-end` / `padding-inline-end` / `inset-inline-end` | right | left |
|
||||
| `border-inline-start` | border-left | border-right |
|
||||
| `border-start-start-radius` | border-top-left-radius | border-top-right-radius |
|
||||
| `text-align: start` / `text-align: end` | left / right | right / left |
|
||||
| `inline-size` / `block-size` | width / height | width / height |
|
||||
|
||||
Browser support: core inline-axis logical properties are Baseline
|
||||
Widely Available (Chrome 87, Safari 14.1, Firefox 66; ≥95% global as
|
||||
of 2026-05).
|
||||
|
||||
**Tailwind v4 changes the answer for new projects.** v4.0 (2025-01-22)
|
||||
folded inline-axis logical utilities into core (`ms-*`, `me-*`, `ps-*`,
|
||||
`pe-*`, `start-*`, `end-*`). v4.2 (2026-02-18) added the block-axis
|
||||
set (`mbs-*`, `mbe-*`, `pbs-*`, `pbe-*`) and renamed the inset
|
||||
utilities: `start-*` / `end-*` are deprecated (still work) in favor
|
||||
of `inset-s-*` / `inset-e-*`. The `tailwindcss-rtl` plugin is obsolete.
|
||||
Don't write `[dir="rtl"]:` overrides for spacing on Tailwind v4.
|
||||
|
||||
## Bidirectional text
|
||||
|
||||
UAX #9 rev 51 (Sept 2025) is a version stamp for Unicode 17.0. No
|
||||
algorithm change; `max_depth = 125` is permanently locked forward.
|
||||
|
||||
UAX #9 defines two distinct families of bidi formatting characters
|
||||
that solve different problems:
|
||||
|
||||
- **Isolate controls** (modern, prefer these): U+2066 LRI, U+2067 RLI, U+2068 FSI — opened with these, all closed with U+2069 PDI. An isolated run does not affect, and is not affected by, the surrounding paragraph's bidi resolution. Use FSI when the embedded run's direction is unknown ahead of time.
|
||||
- **Embedding / override controls** (legacy): U+202A LRE, U+202B RLE, U+202D LRO, U+202E RLO — all closed with U+202C PDF. These nest within the surrounding paragraph rather than isolating from it; LRO/RLO additionally force a direction onto neutral characters. Newer code should use isolates; touch embeddings only when interoperating with text from systems that emit them.
|
||||
|
||||
**Use `<bdi>` in HTML; in plain text, pick the isolate that matches
|
||||
what you know about the run.** UAX #9 §2.7: *"where available, markup
|
||||
should be used instead of the explicit formatting characters."*
|
||||
`<bdi>` has been Baseline Widely Available since January 2020.
|
||||
Reach for control characters only in plain-text contexts (logs,
|
||||
plain-text emails, terminal output). When you do:
|
||||
|
||||
- **LRI U+2066 + PDI U+2069** for known-LTR runs (English name in an Arabic paragraph, code-style identifiers, phone numbers).
|
||||
- **RLI U+2067 + PDI U+2069** for known-RTL runs (Arabic name in an English paragraph).
|
||||
- **FSI U+2068 + PDI U+2069** for unknown direction (UGC where the author and language can vary).
|
||||
|
||||
Don't reach for FSI as the default — it auto-detects from the first
|
||||
strong character, which is the wrong choice when you already know
|
||||
what direction the run should be.
|
||||
|
||||
`dir="auto"` on a paragraph or `<bdi>` lets the browser detect
|
||||
direction from the first strong directional character. Best for
|
||||
user-generated content where direction isn't known at author time.
|
||||
|
||||
## What mirrors and what doesn't
|
||||
|
||||
Mirroring isn't universal. The rules below are unanimous across
|
||||
Material 3 RTL guidance and Apple HIG internationalization.
|
||||
|
||||
**Must mirror:**
|
||||
|
||||
- Directional arrows (back / forward / next / previous), navigation rail position, tab order, calendar-grid weekday order.
|
||||
- Slider fill direction and **non-media** progress-bar fill (a download progress bar, a form-completion bar, an upload status). Media scrubbers stay LTR — see the Media row below.
|
||||
- Checkbox-and-label position. Label sits to the right in LTR, to the left in RTL.
|
||||
- Phone-number and IBAN affordances when the surrounding paragraph is RTL but the value itself is LTR — wrap the value in `<bdi dir="ltr">` (or `<span dir="ltr">`) so the digits don't reflow. Bare `<bdi>` is not enough: phone numbers and account numbers contain mostly weak / neutral characters, so first-strong direction detection is unreliable. Force LTR explicitly.
|
||||
|
||||
**Must not mirror:**
|
||||
|
||||
- Clock faces. Clockwise is universal.
|
||||
- Circular refresh / sync / reload icons. Same reason.
|
||||
- Media playback controls (play / pause / fast-forward / rewind) **and the media scrubber / progress timeline**. They represent tape direction, not reading direction.
|
||||
- Charts and graphs. X-axis stays mathematical, not linguistic.
|
||||
- Photographs, brand logos, physical-object icons (camera, keyboard, headphones). Identity over direction.
|
||||
|
||||
**Numerals are not a mirroring decision.** They follow locale, not
|
||||
paragraph direction. Arabic-Indic digits carry bidi class **AN**, not
|
||||
EN — affects how they sit inside mixed-direction lines but does not
|
||||
flip them.
|
||||
|
||||
**Single live conflict between platforms:** the search icon. SF Symbols
|
||||
ships an RTL `magnifyingglass` variant (Apple flips it). Material 3
|
||||
says don't flip the magnifying glass (handle stays bottom-right).
|
||||
Decide per-platform; don't synthesize a single rule.
|
||||
|
||||
## Typography rules anchored here
|
||||
|
||||
Two RTL-coupled typography rules sit in this file because they cause
|
||||
breakage at the layout level. The full Arabic / Hebrew typography
|
||||
guide (font picks, harakat line-height, OpenType shaping, mixed-script
|
||||
fallback chains) belongs in a future `craft/arabic-hebrew-typography.md`.
|
||||
|
||||
- **Never apply CSS `letter-spacing` to Arabic runs.** alreq treats
|
||||
letter-spacing as a boundary concept, not a uniform tracking value.
|
||||
Applying tracking breaks the cursive joining the script depends on.
|
||||
- **Body type for Arabic runs ~14-18 px with line-height 1.5-1.75** to
|
||||
give harakat (diacritics) clearance. Latin defaults are too tight.
|
||||
|
||||
## Native mobile RTL parity
|
||||
|
||||
Web RTL handling does not auto-translate to mobile. Each platform has
|
||||
its own direction primitive. Skills that emit web-only artifacts can
|
||||
skim this section; it's the entry point for skills that ship to
|
||||
mobile (mobile-onboarding, mobile-app, etc.).
|
||||
|
||||
| Platform | Direction primitive | Spacing |
|
||||
|---|---|---|
|
||||
| iOS UIKit | `semanticContentAttribute = .forceRightToLeft` | `NSDirectionalEdgeInsets` |
|
||||
| iOS SwiftUI | `.environment(\.layoutDirection, .rightToLeft)` | `EdgeInsets` with `leading` / `trailing` |
|
||||
| Android Compose | `CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl)` | `PaddingValues` accepts start / end |
|
||||
| Flutter | `Directionality(textDirection: TextDirection.rtl)` | `EdgeInsetsDirectional.fromSTEB(...)` |
|
||||
| React Native | `I18nManager.forceRTL(true)` (requires native reload; no `forceLTR` parity, no `react-native-web` support) | `marginStart` / `marginEnd` |
|
||||
|
||||
The rule across all platforms: prefer the directional primitive over
|
||||
the absolute one. `EdgeInsets.left/right` in Flutter, `paddingLeft` /
|
||||
`paddingRight` in Android, leading-vs-trailing in iOS — these are bugs
|
||||
waiting for an Arabic deployment.
|
||||
|
||||
## Forms in RTL
|
||||
|
||||
Form fields commonly mix scripts. Three rules cover most of it.
|
||||
|
||||
- **`<input dir="auto">`** for any field whose value's direction is uncertain (search boxes, comment fields, free-text inputs). The browser detects from the first strong directional character.
|
||||
- **Force LTR on intrinsically-LTR fields** even inside an RTL paragraph: email, URL, phone, IBAN, credit-card. `<input type="email" dir="ltr">`.
|
||||
- **Wrap rendered values in `<bdi>`** when displaying mixed-script content (a username inside a paragraph, a model number inside a description). Stops the surrounding direction from rearranging the embedded value. For values whose direction is fixed and weak-character-heavy (phone, IBAN, card number), use `<bdi dir="ltr">` rather than bare `<bdi>` so first-strong detection doesn't misclassify.
|
||||
|
||||
## Common mistakes (lint these)
|
||||
|
||||
Mechanically lintable items can be flagged from CSS / source alone.
|
||||
Script-aware items need to detect Arabic / Hebrew runs in the
|
||||
rendered text and have legitimate exceptions (chart axes, physical
|
||||
icons, platform-specific placement).
|
||||
|
||||
**Mechanically lintable:**
|
||||
|
||||
- Hardcoded `left` / `right` / `text-align: left` in new CSS — bug for any layout that may render RTL. Exceptions: chart x-axes, physical-object icons, platform-pinned UI like a status-bar clock. Lint with an allow-list rather than blanket banning.
|
||||
- "Tailwind v4.2 logical-utility rename is `inline-s-*` / `inline-e-*`" — wrong family. Those are size utilities. The inset rename is `inset-s-*` / `inset-e-*`.
|
||||
- "WebKit doesn't support U+2066-U+2069" — wrong, they're interoperable across modern browsers. The "still missing" claim traces to a stale 2015 W3C test snapshot.
|
||||
- Setting `dir="rtl"` without `lang="ar"` (or matching). Lint together; `dir` alone misses the font-stack and locale path.
|
||||
- Flutter `EdgeInsets.left/right` in code that needs to render RTL. Use `EdgeInsetsDirectional.start/end`.
|
||||
|
||||
**Needs script detection (will false-positive without it):**
|
||||
|
||||
- "Use `text-justify: kashida` for Arabic" — no browser implements it. CSS `text-align: justify` adds inter-word spacing and looks unnatural in Arabic; kashida elongation is the correct form, but it isn't shippable on the web today.
|
||||
- Italics on Arabic or Hebrew text. Neither script has an italic tradition.
|
||||
- CSS `letter-spacing` applied to Arabic. Breaks cursive joining (alreq treats it as a boundary concept, not a uniform tracking value).
|
||||
- Lorem Ipsum used for RTL prototyping. Arabic word lengths, connection behaviors, and vertical extents differ; use real Arabic / Hebrew text.
|
||||
|
||||
**HTML semantics:**
|
||||
|
||||
- Reaching for CSS bidi controls (`unicode-bidi: isolate` / `plaintext` / `embed`) for inline runs when `<bdi>` or a `dir`-bearing element does the job. Prefer semantic isolation in HTML for inline content; `unicode-bidi: plaintext` operates on a different surface (it changes how base direction is determined for each plaintext paragraph in a block) and should only be used when that block-level paragraph behavior is explicitly required and tested. The two are not drop-in equivalents — don't lint one as a replacement for the other.
|
||||
- Bare `<bdi>` around phone / IBAN / card numbers in an RTL paragraph. First-strong detection on weak/neutral characters is unreliable; force `dir="ltr"` explicitly.
|
||||
134
craft/state-coverage.md
Normal file
134
craft/state-coverage.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# State coverage craft rules
|
||||
|
||||
Universal rules for what every interactive surface must render. The active
|
||||
`DESIGN.md` decides how each state looks; this file decides which states must
|
||||
exist and what they must contain. The single most reliable AI-design failure
|
||||
is shipping only the populated state.
|
||||
|
||||
> Distilled from WCAG 2.2, NN/g, Material Design 3, Apple HIG, and Baymard
|
||||
> Institute checkout research.
|
||||
|
||||
## The five required states
|
||||
|
||||
Every surface that fetches, transforms, or accepts data must render all five.
|
||||
|
||||
| State | Triggered when | Must contain |
|
||||
|---|---|---|
|
||||
| **Loading** | Data is in flight | Skeleton, spinner, or shell — plus a 15 s "taking longer than expected" fallback |
|
||||
| **Empty** | No records yet, or query returned nothing | Headline, plain explanation, primary CTA |
|
||||
| **Error** | Fetch failed, server failure, validation rejection | Plain-language cause, recovery action, preserved user input |
|
||||
| **Populated** | Data present, primary case | The state the design was actually drawn for |
|
||||
| **Edge** | Extreme volume, long strings, missing optional fields, RTL or long-word content, partial network | Layout that does not break |
|
||||
|
||||
Render-and-screenshot test: every list, table, card, form, and panel in the
|
||||
artifact has all five. Missing states are the most common silent failure of
|
||||
AI-generated UI.
|
||||
|
||||
**Test matrix.** Concrete edge scenarios the surface must survive:
|
||||
|
||||
| Skill type | Edge scenario |
|
||||
|---|---|
|
||||
| Dashboard / table | 10,000+ rows, all numeric columns, sort + filter applied |
|
||||
| Mobile card / list | 200-char title, missing avatar, missing secondary CTA |
|
||||
| Form | All optional fields empty, all required fields at max length |
|
||||
| Search results | Single-character query, query with only special chars, 1,000+ result count |
|
||||
| Detail view | Missing all optional metadata, RTL primary content with LTR embeds |
|
||||
|
||||
## Form-specific states
|
||||
|
||||
Forms add three states on top of the five.
|
||||
|
||||
| State | Triggered when | Behavior |
|
||||
|---|---|---|
|
||||
| **Untouched** | Field has not yet had focus | Default styling; no validation messages |
|
||||
| **Dirty (valid)** | User typed and field passes validation | Persistent helper text remains; no success-coloring |
|
||||
| **Submitted-pending** | Submit clicked, awaiting server | Submit button enters loading state; fields lock against re-submission |
|
||||
|
||||
Validation timing: validate **on blur**, not on first keystroke. For password
|
||||
and similar live fields, validate on each keystroke *only after the first
|
||||
blur*. Remove the error message the instant input becomes valid.
|
||||
|
||||
## Empty state composition
|
||||
|
||||
Empty is not the absence of state. It is its own state with a job.
|
||||
|
||||
- **First-use empty** — illustration + headline + value sentence + primary CTA. The empty is the onboarding moment.
|
||||
- **No-results empty** — echo the query, suggest alternatives, never leave a true blank.
|
||||
- **Cleared empty** — celebratory phrasing, optional next-action.
|
||||
- **Error-as-empty** — never. An error is its own state with recovery information; do not collapse error into empty.
|
||||
|
||||
**Server-driven vs client-driven.** When a search or query API can return fallback content in the empty payload (suggestions, related categories, popular results), prefer that over a client-side echo. Algolia, Elastic, and most modern search backends support this — the server has more context for what "no results, but maybe try X" should mean.
|
||||
|
||||
## Error state composition
|
||||
|
||||
Every error must answer three questions, in this order:
|
||||
|
||||
1. **What happened.** "Your card was declined." Not "Something went wrong."
|
||||
2. **Why, if knowable.** "Insufficient funds." Or "Network unreachable — check your connection."
|
||||
3. **What the user can do.** A retry button, an alternative path, or a support link.
|
||||
|
||||
Preserve user input across the error. The form must not clear on submit
|
||||
failure.
|
||||
|
||||
Severity tiers:
|
||||
|
||||
- **Field-level** — red border, inline message, focus moves to the field.
|
||||
- **Form-level** — error summary banner at top + per-field markers.
|
||||
- **Section-level** — inline panel with retry, surrounding sections still functional.
|
||||
- **Page-level** — full error state with illustration and recovery CTA.
|
||||
- **App-level** — persistent banner or modal for critical loss-of-functionality.
|
||||
|
||||
Match severity to surface scope. A field validation failure does not warrant
|
||||
a page-level error.
|
||||
|
||||
**Retry discipline.** A retry surface is not a button alone. It has timing rules:
|
||||
|
||||
- First retry fires immediately on user click.
|
||||
- Second and third retries use exponential backoff: 2 s, 4 s, 8 s max.
|
||||
- After 3 failed retries, replace "Retry" with "Contact support" plus a copyable error ID. The user has done their job; the system now needs a human.
|
||||
- Show "Last attempted: Xs ago" on the error surface after the first retry, so the user knows how stale the failure is.
|
||||
|
||||
## Loading state thresholds
|
||||
|
||||
Pick the indicator by expected duration, not by what's available in the
|
||||
component library.
|
||||
|
||||
| Duration | Indicator |
|
||||
|---|---|
|
||||
| 0–300 ms | None. Render synchronously; users perceive no delay. |
|
||||
| 300 ms – 2 s | Subtle spinner or skeleton. |
|
||||
| 2 – 10 s | Skeleton matched to expected layout, or labelled spinner ("Loading payments…"). |
|
||||
| 10 – 30 s | Determinate progress bar with cancel option. |
|
||||
| 30 – 60 s | Progress bar with explicit cancel affordance. The "taking longer than expected" notice already appeared at 15 s; do not repeat it. |
|
||||
| 60 s+ | Stop animation. Show error with retry, cancel, or continue. |
|
||||
|
||||
Never leave a spinner running indefinitely. Start a timeout on every request.
|
||||
|
||||
## ARIA and focus rules
|
||||
|
||||
State changes must be announced and focused correctly.
|
||||
|
||||
| Change | ARIA | Focus action |
|
||||
|---|---|---|
|
||||
| Inline error on submit | `role="alert"` on the message | Move focus to first error field |
|
||||
| Toast / non-urgent confirmation | `role="status"` (polite live region) | Do not move focus |
|
||||
| Critical error or destructive confirmation | `role="alertdialog"` (assertive) | Move focus to dialog |
|
||||
| Loading begins | `role="status"` announcement ("Loading…") | Do not move focus to spinner |
|
||||
| Loading ends, content appears | — | Move focus to loaded content if action was user-initiated |
|
||||
|
||||
Live region containers must exist in the DOM before content is injected.
|
||||
Adding `aria-live` simultaneously with content does not trigger an
|
||||
announcement.
|
||||
|
||||
## Common mistakes (lint these)
|
||||
|
||||
- Surface renders only the populated state; loading, empty, error, and edge are absent.
|
||||
- Empty state is a literal blank or "No data" text with no headline, explanation, or action.
|
||||
- Error message reads "Something went wrong" with no cause or recovery.
|
||||
- Spinner with no timeout; runs indefinitely on slow or failed requests.
|
||||
- Submit clears form fields on validation failure, forcing re-entry.
|
||||
- Inline validation fires on first keystroke instead of on blur.
|
||||
- Full-page loading replaces the chrome when only one section is fetching.
|
||||
- Toast appears at a different screen position than previous toasts in the same artifact.
|
||||
- Color alone conveys error state — no icon, no text label.
|
||||
- Auto-dismissing toast cannot be paused on hover or focus (WCAG SC 2.2.1).
|
||||
88
craft/typography.md
Normal file
88
craft/typography.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Typography craft rules
|
||||
|
||||
Universal typography rules that apply on top of any `DESIGN.md`. The
|
||||
active design system decides *which* fonts; this file decides *how* they
|
||||
behave at every size.
|
||||
|
||||
> Adapted from [refero_skill](https://github.com/referodesign/refero_skill)
|
||||
> (MIT) — distilled and re-tuned for Open Design's token system.
|
||||
|
||||
## Type scale
|
||||
|
||||
Use a multiplicative scale (1.2 or 1.25). Cap at 6–8 sizes per artifact.
|
||||
|
||||
| Role | Range |
|
||||
|---|---|
|
||||
| Display | 48–72 px |
|
||||
| H1 | 32–48 px |
|
||||
| H2 | 24–32 px |
|
||||
| H3 | 20–24 px |
|
||||
| Body | 15–18 px |
|
||||
| Small | 13–14 px |
|
||||
| Caption | 11–12 px |
|
||||
|
||||
## Line height (leading)
|
||||
|
||||
| Text size | Line height |
|
||||
|---|---|
|
||||
| Display / H1 (≥32 px) | `1.0`–`1.2` (tight) |
|
||||
| Body (15–18 px) | `1.5`–`1.6` |
|
||||
| Small (≤14 px) | `1.5` |
|
||||
|
||||
## Letter-spacing — the rule that makes or breaks craft
|
||||
|
||||
This is the single most-skipped rule in AI-generated design. **No
|
||||
exceptions.**
|
||||
|
||||
| Context | Letter-spacing |
|
||||
|---|---|
|
||||
| Body text (14–18 px) | `0` (default) |
|
||||
| Small text (11–13 px) | `0.01em` to `0.02em` (positive) |
|
||||
| UI labels and button text | `0.02em` |
|
||||
| **ALL CAPS** | **`0.06em` to `0.1em` (required)** |
|
||||
| Headings 32 px+ | `-0.01em` to `-0.02em` |
|
||||
| Display 48 px+ | `-0.02em` to `-0.03em` |
|
||||
|
||||
ALL CAPS without positive tracking looks cramped and amateur. Display
|
||||
text without negative tracking looks loose and weak. These two failures
|
||||
are the most reliable AI-slop tells.
|
||||
|
||||
The `0.06em` floor is not arbitrary: it is the empirical lower bound
|
||||
that print and web typographers have converged on for uppercase
|
||||
tracking (cf. Bringhurst's *Elements of Typographic Style* §3.2.7,
|
||||
which recommends 5–10% of the em for caps; modern screen practice
|
||||
rounds the lower end to 0.06em). Anything tighter and the counters
|
||||
collide on screen; the upper bound `0.1em` keeps the word from
|
||||
disintegrating into letters.
|
||||
|
||||
## Font pairing
|
||||
|
||||
- Maximum 2 typefaces per artifact (display + body, or one variable face
|
||||
used at multiple weights).
|
||||
- Always declare a system fallback chain. If the active `DESIGN.md`
|
||||
ships a webfont URL, the fallback must still produce a coherent look.
|
||||
- Never set `font-family: system-ui` alone on a heading — that is the
|
||||
textbook AI default; always pair it with an intentional first choice.
|
||||
|
||||
## Line length
|
||||
|
||||
Limit body copy to **50–75 characters** per line. In CSS:
|
||||
`max-width: 65ch` is a safe default.
|
||||
|
||||
## Three-weight system
|
||||
|
||||
Most well-crafted UIs use exactly 3 weights:
|
||||
- **Read** (400 / 450) — body copy
|
||||
- **Emphasize** (510 / 550) — UI text, labels, navigation
|
||||
- **Announce** (590 / 600) — headlines, buttons
|
||||
|
||||
Weight 700+ is rarely needed. If your design uses bold for "emphasis on
|
||||
emphasis," it likely lacks weight discipline elsewhere.
|
||||
|
||||
## Common mistakes (lint these)
|
||||
|
||||
- ALL CAPS without `letter-spacing` ≥ `0.06em`.
|
||||
- Display text (≥32 px) without negative tracking.
|
||||
- More than 3 type sizes visible above the fold.
|
||||
- Mixed serif and slab on the same screen without a clear role split.
|
||||
- Body copy in `text-align: justify` (creates rivers; never use on the web).
|
||||
Reference in New Issue
Block a user