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

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:
marco
2026-05-06 20:50:24 +02:00
commit 5dd70b5016
1336 changed files with 287186 additions and 0 deletions

84
craft/README.md Normal file
View 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.

View 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.

View 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 |
|---|---|
| 50100 ms | Instant feedback (button press, toggle commit, hover) |
| 150 ms | Default for state-confirmation |
| 200300 ms | Entering UI (modals, sheets, dropdowns) |
| 300500 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 2030% 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 (501000 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 3001000 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
View 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.61.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
View 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** | 7090% | `--bg`, `--surface`, `--fg`, `--muted`, `--border` |
| **Accent** (one) | 510% | `--accent` only — never invent a second accent |
| **Semantic** | 05% | `--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
View 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
View 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
View 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 |
|---|---|
| 0300 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
View 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 68 sizes per artifact.
| Role | Range |
|---|---|
| Display | 4872 px |
| H1 | 3248 px |
| H2 | 2432 px |
| H3 | 2024 px |
| Body | 1518 px |
| Small | 1314 px |
| Caption | 1112 px |
## Line height (leading)
| Text size | Line height |
|---|---|
| Display / H1 (≥32 px) | `1.0``1.2` (tight) |
| Body (1518 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 (1418 px) | `0` (default) |
| Small text (1113 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 510% 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 **5075 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).