④段整套同步(六节 + P/S/F/J + 模板)+ open-design 移到技能根
一、④段整套同步(用户选候选 A)
- 新增 `references/stage-proto-doc/assets/产品文档模板.html`(16 KB/247 行,从备份拷、转 LF)。
- ④段 SKILL.md 换成备份 15:28 那版,并按模板口径改齐:
· 「产品文档框架」从「四块+一附录」改成**六节** —— 页面结构/功能清单/使用场景/操作流程/
跨页与界面状态/待确认与偏离;与模板同构,⛔ 不从空白页重写结构。
· 编号从 R/S/F 改成 **P 页面/S 场景/F 功能/J 二级功能**,各自唯一、按展示顺序通排。
· 保留旧版有价值的要求:每项功能补齐 做什么/入口在哪/输入边界/异常与恢复;重点功能三处标红;
横向流程图每步写清页面/功能/操作/完成判据。
· 完成标准「七节」→「六节」,自查清单同步换掉「需求」那批条目,补模板与配图两条。
· 路径 `designs/<项目>/` → `docs/pm/<项目>/ui/`(2 处)。
· 补回本机那条「每份产出文档开头都要有一张图」的引用(指向主 skill,段内不复述)。
- 主 skill 同步:四段结构表④行、④段沿革、画图小节(④段画页面结构图 + 用户路径)。
二、open-design 移到技能根(用户选候选 A)
- `git mv product-planning/references/stage-delivery/assets/open-design → open-design/`(4178 文件)。
- `.gitignore` 白名单加 `!/open-design/` —— 否则移出去就不再纳入版本控制。
- 引用改 11 处:路径形态 `assets/open-design/...` → `<技能根>/open-design/...`;
「随段携带的自带件」改称「同根技能」;「路径怎么找」那条改成自带件(段内相对路径)与
同根技能(`<技能根>/<技能名>/`)两类分开写。
- open-design 自己的 ATTRIBUTION.md 两处同步(它现在与 oil-ui-pro 同层)。
- 副作用(有益):命名闸门读数由 2063 降到 225 —— 此前它把 open-design 里 919 个第三方 md
当成规则一起扫了;现在只扫本包 23 个 md。已确认④段的 H1 与 4a/4b/4c 子步仍在检查范围内。
三、验收
- check_naming.py --ws E:/ProgramData/AIProject/contentm_agent = 通过 225 / 失败 0 / 提示 27。
- 活文件里 `assets/open-design` 残留 0;工作区 CRLF 文件 0。
This commit is contained in:
1 parent
fa0a9026c8
commit
5afeb6b78a
4184 files changed
+326
-70
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# Future Craft Sections
|
||||
|
||||
These slugs are intentionally referenced by skills before their matching
|
||||
`craft/<slug>.md` sections ship. `pnpm lint:craft` treats them as planned
|
||||
forward references while still failing on unlisted typos.
|
||||
|
||||
- motion-discipline
|
||||
- pixel-discipline
|
||||
- typographic-rhythm
|
||||
@@ -0,0 +1,106 @@
|
||||
# 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 fourth axis next to skills, templates, and design systems
|
||||
|
||||
| Axis | Scope | Example |
|
||||
|---|---|---|
|
||||
| `skills/` | Functional capabilities invoked while doing work | `design-brief`, `brand-extract`, `imagegen` |
|
||||
| `design-templates/` | Packaged artifact shapes | `saas-landing`, `dashboard`, `pricing-page` |
|
||||
| `design-systems/` | Brand package: prose, token contract, and optional rich resources | `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]
|
||||
```
|
||||
|
||||
Use the layered stack for editorial skills that require authored hierarchy
|
||||
and sustained reading behavior:
|
||||
|
||||
```yaml
|
||||
od:
|
||||
craft:
|
||||
requires: [typography, typography-hierarchy, typography-hierarchy-editorial]
|
||||
```
|
||||
|
||||
Allowed shipped values match the file names in this directory minus the `.md`
|
||||
extension. The runtime skips an absent file for compatibility, but repository
|
||||
authoring does not silently accept arbitrary values.
|
||||
|
||||
Run `pnpm lint:craft` after adding or changing `od.craft.requires`. The
|
||||
repository guard reports unresolved slugs with their manifest paths, so typos
|
||||
cannot silently drop a craft section from the runtime prompt. If a slug is an
|
||||
intentional forward reference, list it in `craft/FUTURE_SECTIONS.md` until the
|
||||
matching `craft/<slug>.md` file ships.
|
||||
|
||||
### Runtime compatibility and authoring failures
|
||||
|
||||
The loader remains tolerant because externally installed or older bundles may
|
||||
reference a section that is unavailable in the current resource set. A missing
|
||||
optional paragraph must not make an otherwise usable runtime bundle fail.
|
||||
|
||||
Checked-in content has a stronger contract: `pnpm lint:craft` and
|
||||
`pnpm guard` fail on malformed slugs and unresolved references. A deliberate
|
||||
forward reference is valid only when it is listed in
|
||||
`craft/FUTURE_SECTIONS.md`; this makes planned work explicit while preserving
|
||||
the runtime compatibility behavior.
|
||||
|
||||
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) |
|
||||
| `typography-hierarchy.md` | `typography-hierarchy` | Any skill that emits typed content where hierarchy must feel authored, not assembled — especially surfaces with a strong entry point, varied levels, or intentional rhythm. Compose with `typography`. |
|
||||
| `typography-hierarchy-editorial.md` | `typography-hierarchy-editorial` | Skills whose primary artifact is a sustained reading surface: `blog-post`, `docs-page`, `digital-eguide`. Requires `typography` + `typography-hierarchy`. |
|
||||
| `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 |
|
||||
| `laws-of-ux.md` | `laws-of-ux` | Any skill whose composition decisions hit named cognitive limits: pricing pages (Hick's, Choice Overload, Von Restorff), dashboards (Pareto, Selective Attention, Working Memory), onboarding (Goal-Gradient, Zeigarnik, Peak-End), modals (Fitts's, Tesler's). Sibling axis to the rendering-rule files above — covers what to compose, not how to render. |
|
||||
|
||||
**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.
|
||||
|
||||
Planned-but-unshipped slugs are recorded in
|
||||
[`FUTURE_SECTIONS.md`](FUTURE_SECTIONS.md); do not hard-code a second future
|
||||
list here.
|
||||
|
||||
## Attribution
|
||||
|
||||
Craft content is adapted from the MIT-licensed
|
||||
[refero_skill](https://github.com/referodesign/refero_skill) project
|
||||
(© Refero Design), with edits to fit OpenDesign's house style and link
|
||||
back to OD's design tokens (`var(--accent)` etc.) instead of generic
|
||||
Tailwind hex values.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 OpenDesign'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.
|
||||
@@ -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 OpenDesign'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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,296 @@
|
||||
# Laws of UX craft rules
|
||||
|
||||
Universal cognitive, perceptual, and behavioral heuristics that decide
|
||||
what a UI composes — how many pricing tiers fit on a screen, where a
|
||||
primary action anchors in scanning order, when a progress indicator
|
||||
earns its place, why a settings list needs grouping. The active
|
||||
`DESIGN.md` decides brand visual language; the existing craft files
|
||||
decide rendering rules (color, typography, motion, states, ARIA, RTL,
|
||||
forms); this file decides composition rules grounded in named research.
|
||||
|
||||
> Distilled from primary sources: Hick (1952) + Hyman (1953), Miller
|
||||
> (1956) for chunking / `7±2` channel capacity, Cowan (2001) for the
|
||||
> modern ~4 working-memory bound, Fitts (1954), Wertheimer (1923) for
|
||||
> proximity / similarity / Prägnanz, Palmer (1992) for Common Region,
|
||||
> Palmer & Rock (1994) for Uniform Connectedness, Kahneman /
|
||||
> Fredrickson / Schreiber / Redelmeier (1993) for Peak-End, Zeigarnik
|
||||
> (1927), Csíkszentmihályi (1975), Hull (1932), von Restorff (1933),
|
||||
> Broadbent (1958), Sweller (1988), Postel (RFC 760, 1980), Carroll &
|
||||
> Rosson (1987), Tversky & Kahneman (1974) for Anchoring, Kurosu &
|
||||
> Kashimura (1995), Iyengar & Lepper (2000), Toffler (1970), Pareto
|
||||
> (c.1906) / Juran (*Quality Control Handbook*, 1951), Ebbinghaus
|
||||
> (1885), Ockham (14th c.), Tesler at Apple (1980s), Nielsen (2000),
|
||||
> Norman *POET* (1988), Parkinson (1955).
|
||||
|
||||
## Prior art and scope
|
||||
|
||||
Existing public catalogs of UX heuristics (Yablonski's lawsofux.com,
|
||||
NN/g's 10 usability heuristics, Material 3 motion + interaction
|
||||
guidance, Apple HIG, Baymard Institute checkout research) inventory the
|
||||
laws but rarely tie each one to a concrete code-gen directive. This
|
||||
file does the translation: every entry ends with one actionable move
|
||||
for an HTML / Tailwind / React-emitting agent. Sibling craft files
|
||||
(`accessibility-baseline.md`, `state-coverage.md`, `typography.md`,
|
||||
`anti-ai-slop.md`, `color.md`, `animation-discipline.md`,
|
||||
`form-validation.md`) own the auto-checked rules; this file names the
|
||||
underlying law and surfaces the folklore. Out of scope: Weber-Fechner
|
||||
psychophysics and Signal Detection Theory — both apply to UI but the
|
||||
prompt-emission directives are too narrow to earn a slot here. Add
|
||||
later if a skill needs them.
|
||||
|
||||
The rules below are guidance, not auto-checked. Reviewers and the agent
|
||||
apply them; the linter does not. Where a law has a sibling rule already
|
||||
auto-checked elsewhere — touch-target floor in
|
||||
`accessibility-baseline.md`, the 300 ms / 2 s / 10 s / 30 s / 60 s
|
||||
loading thresholds in `state-coverage.md`, ALL CAPS letter-spacing in
|
||||
`typography.md`, the indigo / gradient / emoji-icon list in
|
||||
`anti-ai-slop.md` — the entry below cross-references rather than
|
||||
duplicates.
|
||||
|
||||
## Perception and visual grouping
|
||||
|
||||
Five Gestalt laws plus three attention-and-recognition laws govern how
|
||||
the eye groups elements before the brain reads them.
|
||||
|
||||
- **Law of Proximity** (Wertheimer, 1923). Objects near each other read
|
||||
as a group. Cheapest grouping signal — cheaper than borders or shared
|
||||
color. Apply variable vertical rhythm: 8–12 px within a group,
|
||||
32–48 px between groups. Uniform spacing reads as nothing being
|
||||
grouped.
|
||||
- **Law of Similarity** (Wertheimer, 1923). Visually similar elements
|
||||
read as a group. Equivalent affordances must share treatment — every
|
||||
list row identical class set, every secondary button identical, every
|
||||
destructive action identical. Visible deviation is reserved for the
|
||||
one item meant to draw attention (the recommended pricing tier, the
|
||||
selected nav item).
|
||||
- **Law of Common Region** (Palmer, 1992). A shared bounded area binds
|
||||
enclosed elements. Use enclosure when proximity is not enough — and
|
||||
reserve it. Concrete numbers: padding ≥16 px inside the region,
|
||||
distinct surface (border + tinted background, or card chrome at
|
||||
≥1 px hairline). A page where every section is bordered destroys the
|
||||
signal.
|
||||
- **Law of Prägnanz / Good Figure** (Wertheimer, 1923). The eye
|
||||
resolves complex layouts into the simplest underlying form. Designs
|
||||
that align with a clear underlying grid (12-column, F-pattern,
|
||||
4-quadrant) feel inevitable; ornate breaks that add nothing semantic
|
||||
feel arbitrary.
|
||||
- **Law of Uniform Connectedness** (Palmer & Rock, 1994). The
|
||||
strongest grouping signal in the Gestalt hierarchy: connected lines,
|
||||
shared toolbars, or bracketing containers tie items together more
|
||||
strongly than proximity or similarity. Use for wizard steps,
|
||||
comparison sets, and explicit navigation flows.
|
||||
- **Selective Attention** (Broadbent, *Perception and Communication*,
|
||||
1958). Cognitive bandwidth is finite. Users filter aggressively and
|
||||
ignore anything that looks irrelevant to their goal — banner blindness
|
||||
comes from this. Reserve the strongest visual contrast for the single
|
||||
goal-relevant action; let supporting content recede in weight.
|
||||
- **Von Restorff Effect** (von Restorff, 1933). The item that differs
|
||||
from a uniform field is the one most likely to be remembered. Make
|
||||
the recommended pricing tier, the active nav item, the warning state
|
||||
visually distinct. Pair contrast with a non-color signal (icon, text
|
||||
label, position) — `accessibility-baseline.md` rules out color-alone
|
||||
signaling.
|
||||
- **Aesthetic-Usability Effect** (Kurosu & Kashimura, Hitachi Design
|
||||
Center, 1995). Visual polish biases perceived usability. Refined
|
||||
typography, generous whitespace, and a calm palette earn the benefit
|
||||
of the doubt for minor friction. Never substitutes for measurable
|
||||
usability or for `state-coverage.md`'s required-states rule.
|
||||
|
||||
## Decision-making
|
||||
|
||||
Six laws govern how fast and how well users decide when an interface
|
||||
offers a choice.
|
||||
|
||||
- **Hick's Law** (Hick, 1952; Hyman, 1953 replication). Decision time
|
||||
grows roughly log(n+1) with the number of equivalent options. Cap any
|
||||
single decision-screen to 3–5 visible primary options; collapse the
|
||||
rest behind a "More" / progressive disclosure pattern; visually
|
||||
distinguish the recommended choice. Aggressive truncation that hides
|
||||
the path forward is the opposite failure mode — surface the full
|
||||
option set, just don't render every option at the same visual weight.
|
||||
- **Choice Overload** (Iyengar & Lepper, *Journal of Personality and
|
||||
Social Psychology*, 2000; framing dates to Toffler, *Future Shock*,
|
||||
1970). Too many roughly-equivalent options stall or abandon the
|
||||
decision. Pricing pages: 3–4 tiers, exactly one marked recommended.
|
||||
Product grids: 6–9 hero cards above the fold. Settings panels: ≤5
|
||||
named groups. Never emit a flat wall of equivalents.
|
||||
- **Anchoring** (Tversky & Kahneman, *Science* 185:1124–1131, 1974).
|
||||
The first number a user sees re-weights every subsequent number.
|
||||
Place the recommended pricing tier where it anchors the comparison;
|
||||
render yearly-billing savings as concrete dollar deltas, not just
|
||||
percentage badges; pre-select the safer default in radio groups.
|
||||
Visual weight matches intended decision weight.
|
||||
- **Pareto Principle / 80-20** (Pareto, c.1906; Juran, *Quality Control
|
||||
Handbook*, 1951 — popularized the management-application framing). A
|
||||
small share of features drives most of the value. Identify the 2–3 actions
|
||||
that drive the dominant journey for the target persona; emphasize
|
||||
those visually; demote the long tail to overflow menus, footer
|
||||
surfaces, or settings.
|
||||
- **Tesler's Law / Conservation of Complexity** (Tesler, Apple, 1980s).
|
||||
Every product has an irreducible amount of complexity. The design
|
||||
choice is *where* it lives — engineering team, interface, user — not
|
||||
whether to eliminate it. When complexity reaches the user, surface
|
||||
contextual guidance (tooltips, smart defaults, inline empty-state
|
||||
coaching, progressive disclosure) at the exact step where it
|
||||
surfaces. Hiding it is not the same as removing it.
|
||||
- **Occam's Razor** (Ockham, 14th c.). Among options that explain the
|
||||
data equally well, prefer the one with the fewest assumptions. Specify
|
||||
a minimal element inventory; forbid decorative chrome that doesn't
|
||||
serve a stated user task. The law constrains assumptions, not feature
|
||||
count — a "minimum viable" framing misreads it.
|
||||
|
||||
## Memory and learning
|
||||
|
||||
Five laws cover how working memory handles information density and
|
||||
what the user retains afterward.
|
||||
|
||||
- **Miller's Law and Chunking** (Miller, *The Magical Number Seven,
|
||||
Plus or Minus Two*, *Psychological Review*, 1956 — channel capacity /
|
||||
`7±2` and chunking; Cowan, *Behavioral and Brain Sciences* 24:1, 2001
|
||||
for the modern ~4-item working-memory bound). Working memory holds
|
||||
about four items reliably and up to seven for short-term recall. Each
|
||||
slot can hold a *larger familiar unit*, constrained by the user's
|
||||
domain knowledge — chunking does not let you pack arbitrary content
|
||||
into a single slot. Often misread as a rule about menu length;
|
||||
Miller's paper is about chunks. Group related fields with clear
|
||||
section headings, dividers, or card containers. A settings page with
|
||||
sections "Account / Notifications / Privacy / Billing / Danger zone"
|
||||
beats one flat list of 30 toggles.
|
||||
- **Working Memory** (Baddeley & Hitch, 1974; lineage to Atkinson &
|
||||
Shiffrin, 1968). Items decay in seconds without rehearsal. Recognition
|
||||
beats recall: persisting prior context across screens, marking visited
|
||||
elements, and surfacing comparison views beats forcing the user to
|
||||
memorize. On dashboards specifically: sticky filter chips, last-N
|
||||
selections persisted, breadcrumbs that include applied filters.
|
||||
- **Serial Position Effect** (Ebbinghaus, *Über das Gedächtnis*, 1885).
|
||||
Recall favors the extremes — primacy at the start, recency at the
|
||||
end — while middle items fade. Anchor the most important nav items at
|
||||
the leftmost and rightmost positions of a horizontal menu; cluster
|
||||
utilities in the middle.
|
||||
- **Peak-End Rule** (Kahneman, Fredrickson, Schreiber, Redelmeier,
|
||||
*Psychological Science*, 1993). Memory of an experience is dominated
|
||||
by the emotional peak and the ending, not the average. Stage a
|
||||
high-effort celebratory success state; let intermediate steps stay
|
||||
calm. Mediocre middles matter less than a strong close. The peak
|
||||
belongs at the *end* of a flow, not as arbitrary mid-flow motion —
|
||||
`animation-discipline.md` rejects motion that performs (rather than
|
||||
confirms) a state change.
|
||||
- **Zeigarnik Effect** (Zeigarnik, *Über das Behalten erledigter und
|
||||
unerledigter Handlungen*, 1927). Uncompleted tasks create cognitive
|
||||
tension that pulls the user back. Visible progress ("3 of 5 steps",
|
||||
greyed-out next sections) converts that tension into completion
|
||||
pressure. Reserve for genuinely beneficial flows like onboarding;
|
||||
applying the same lever to streaks, daily-quest counters, or notification-
|
||||
reduction nags is a dark pattern.
|
||||
|
||||
## Interaction and motor
|
||||
|
||||
Five laws cover how fast and how accurately users can act on the UI.
|
||||
|
||||
- **Fitts's Law** (Fitts, *Journal of Experimental Psychology*, 1954).
|
||||
Time to acquire a target depends on its distance and size — bigger
|
||||
and closer is faster. Spacing between adjacent hit zones matters as
|
||||
much as size. Pair with `accessibility-baseline.md`'s 24 × 24 CSS px
|
||||
AA touch-target floor; on mobile, place high-frequency controls in
|
||||
the natural thumb arc.
|
||||
- **Doherty Threshold** (Doherty & Thadani, *IBM Systems Journal*,
|
||||
1982). Sub-second feedback keeps users in flow; latency above ~1 s
|
||||
breaks attention. The implementable directive lives in
|
||||
`state-coverage.md`'s loading-threshold table (no indicator under
|
||||
300 ms; skeleton 300 ms – 2 s; labelled spinner 2 – 10 s; determinate
|
||||
bar with cancel 10 – 60 s; stop and offer error/retry past 60 s).
|
||||
This entry exists to name the underlying law and flag its folklore
|
||||
(the 400 ms number doesn't appear in the 1982 paper — see
|
||||
`animation-discipline.md` for the 400 ms folklore trace).
|
||||
- **Flow** (Csíkszentmihályi, *Beyond Boredom and Anxiety*, 1975). Flow
|
||||
sits in the balance between challenge and skill — too hard breeds
|
||||
frustration, too easy breeds boredom. Continuous feedback and a clear
|
||||
sense of control keep the user inside the state. System friction and
|
||||
latency are the fastest ways to break it.
|
||||
- **Goal-Gradient Effect** (Hull, *Psychological Review*, 1932; Kivetz,
|
||||
Urminsky, Zheng, 2006 for the punch-card replication). Motivation to
|
||||
finish rises as the goal gets closer. Multi-step flows render with a
|
||||
prominent progress indicator that reflects *real* endowed progress —
|
||||
show completed prerequisites when they truly exist (saved profile,
|
||||
imported team, prior survey answer). When no real prerequisite
|
||||
exists, render the current step honestly as `1 of N` with the
|
||||
empty/current-step state clearly marked. Hull's hypothesis is
|
||||
descriptive; treating it as license for fabricated progress, streak
|
||||
dark patterns, or loyalty-program quota inflation is a misread.
|
||||
- **Postel's Law / Robustness Principle** (Postel, RFC 760, 1980). "Be
|
||||
liberal in what you accept, conservative in what you send." Take
|
||||
input in whatever shape users naturally give it (phone numbers with
|
||||
or without dashes, dates in mixed formats, percentages with or
|
||||
without `%`); normalize internally to a canonical form; emit one
|
||||
consistent format on output. The error-timing and ARIA-wiring half
|
||||
lives in `form-validation.md`; the input-tolerance half is the
|
||||
directive above. RFC 9413 (Thomson, IAB, 2023; built on the earlier
|
||||
`draft-iab-protocol-maintenance`) retracts the maxim for protocol
|
||||
design citing security surface; the UX-input application stands.
|
||||
|
||||
## Behavior and expectation
|
||||
|
||||
Five laws cover what users predict and how that prediction interacts
|
||||
with the rendered surface.
|
||||
|
||||
- **Jakob's Law** (Nielsen, Nielsen Norman Group, 2000). Users spend
|
||||
most of their time on other sites and expect yours to work the same.
|
||||
Reuse category convention — nav placement, cart icon, settings gear,
|
||||
primary CTA in the upper right of a SaaS landing — so the user spends
|
||||
zero cycles relearning interaction grammar. Novelty must earn its
|
||||
keep against the convention's ROI; "innovate everywhere" is the
|
||||
opposite failure mode.
|
||||
- **Mental Model** (Craik, *The Nature of Explanation*, 1943; Norman,
|
||||
*POET / The Psychology of Everyday Things*, 1988). Every user
|
||||
arrives with a prior built from competitor products and the physical
|
||||
world. When the prediction holds, the product feels intuitive; when
|
||||
it breaks, friction shows up as confusion, not curiosity. When the
|
||||
brief names a reference product, anchor explicitly — capture the
|
||||
reference in the prompt and the agent inherits a transferable
|
||||
interaction grammar.
|
||||
- **Paradox of the Active User** (Carroll & Rosson, in *Interfacing
|
||||
Thought: Cognitive Aspects of Human-Computer Interaction*, MIT Press,
|
||||
1987, pp. 80–111). Users skip the manual and start using the
|
||||
software immediately, even when reading it would speed them up. Bake
|
||||
guidance into the surface itself — empty-state coaching, inline
|
||||
tooltips, contextual hints — at the action point.
|
||||
- **Parkinson's Law** (Parkinson, *The Economist*, 1955). Work expands
|
||||
to fill the time allotted to it. Loose interfaces let users dawdle;
|
||||
cut friction and pre-fill what you can — autofill, smart defaults,
|
||||
saved state — so a checkout finishes faster than the user expected.
|
||||
Beating anticipated duration becomes the felt win.
|
||||
- **Cognitive Load** (Sweller, *Cognitive Science*, 1988). Total
|
||||
mental effort splits into intrinsic (the task's inherent difficulty)
|
||||
and extraneous (poor layout, jargon, inconsistent patterns, visual
|
||||
noise). Designers can't reduce intrinsic load; they own extraneous
|
||||
fully. The visual-restraint directives (single accent in `color.md`,
|
||||
three-weight typography rhythm in `typography.md`, P0 anti-default
|
||||
list in `anti-ai-slop.md`) already constrain extraneous load; this
|
||||
entry exists to name the cognitive cost the sibling rules reduce.
|
||||
|
||||
## Common mistakes (lint these)
|
||||
|
||||
Folklore corrections that don't survive a primary-source check, in the
|
||||
same vein as the busts already documented in `animation-discipline.md`
|
||||
and `accessibility-baseline.md`. The first three are attribution
|
||||
corrections (year / venue / institution) the body entries already
|
||||
applied — restated here so a reviewer reading just this section sees
|
||||
them. The remainder are folklore not addressed in the body.
|
||||
|
||||
- "Anchoring effect = Tversky & Kahneman 1972." Wrong year. The
|
||||
*Science* paper introducing the anchoring framing is 1974; the 1972
|
||||
paper is "Subjective probability: A judgment of representativeness"
|
||||
(different work).
|
||||
- "Tesler's Law was developed at Xerox PARC." Tesler left PARC for
|
||||
Apple in 1980; the Conservation-of-Complexity formulation traces to
|
||||
his Apple years.
|
||||
- "Paradox of the Active User was a CACM article." It's a chapter in
|
||||
Carroll's *Interfacing Thought* (MIT Press, 1987), not CACM.
|
||||
- "Selective Attention = solved by red dots and badges." Repeated
|
||||
attention-grabbers train banner blindness. Reserve the strongest
|
||||
contrast for one goal-relevant action per surface.
|
||||
- "Fitts's Law alone is enough for touch targets." Fitts gives the
|
||||
speed-accuracy tradeoff. WCAG 2.2 SC 2.5.8 sets the AA floor at
|
||||
24 × 24 CSS px (`accessibility-baseline.md`); iOS HIG suggests
|
||||
44 × 44 pt; Material 3 suggests 48 × 48 dp. Fitts plus the
|
||||
platform floor — never just Fitts.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,182 @@
|
||||
# Editorial typography hierarchy craft rules
|
||||
|
||||
Extends `typography.md` + `typography-hierarchy.md`. Defines hierarchy
|
||||
behavior for editorial surfaces: long-form articles, magazine layouts,
|
||||
digital guides, editorial landing pages, and blog posts.
|
||||
|
||||
> Opt in via `od.craft.requires: [typography, typography-hierarchy, typography-hierarchy-editorial]`.
|
||||
|
||||
---
|
||||
|
||||
## What "editorial" means here
|
||||
|
||||
Editorial hierarchy means the pacing is authored the way a print art director
|
||||
paces a spread: entry point, tension, rest, disruption, resolution. The reader
|
||||
is moved through content rather than given a uniform reading surface. SaaS
|
||||
hierarchy is additive — elements stack and each gets its turn. Editorial
|
||||
hierarchy is compositional — elements are weighted against each other and
|
||||
some are deliberately suppressed so others can breathe.
|
||||
|
||||
---
|
||||
|
||||
## Editorial hierarchy principles
|
||||
|
||||
### 1. Dramatic scale jumps
|
||||
|
||||
Editorial type scales are not gradual. The gap between display and body
|
||||
is large — often 3–5× — because the display element is not just a heading,
|
||||
it is a visual event.
|
||||
|
||||
| Level | Typical range | Notes |
|
||||
|---|---|---|
|
||||
| Display / lede | 56–96 px | (editorial override) May intentionally exceed the default `typography.md` display range |
|
||||
| Deck / standfirst | 18–24 px | Large jump down — intentional |
|
||||
| Body | 16–18 px | Close to deck is fine; they're in the same reading register |
|
||||
| Pull quote | 28–40 px | Disrupts body rhythm; treated as a visual break, not a heading |
|
||||
| Caption / label | 11–13 px | Minimal — never competes with body |
|
||||
|
||||
The gap between display and deck is the editorial signature. A small step
|
||||
here reads as SaaS, not editorial.
|
||||
|
||||
### 2. Whitespace carries hierarchy
|
||||
|
||||
Editorial hierarchy is not announced by a heavy heading. It is created by
|
||||
the space that surrounds an element. An article title in a moderate weight
|
||||
surrounded by generous whitespace outranks a bold heading crammed against
|
||||
its content.
|
||||
|
||||
Rules:
|
||||
- Above-the-fold display element: minimum 2× the line-height in space above
|
||||
and below before body begins.
|
||||
- Pull quotes: full column margin on both sides, or break the grid entirely.
|
||||
- Section breaks: use space as the default hierarchy signal. Separators (rules, dingbats,
|
||||
folios, chapter marks) are allowed only when they reinforce publication identity or
|
||||
distinguish unrelated content. For RTL layouts, mirror or adapt separators using
|
||||
logical directions (inline-start, inline-end) rather than physical (left, right).
|
||||
- Caption clusters: tighter internal spacing, larger gap from the body above.
|
||||
|
||||
### 3. Restrained bold
|
||||
|
||||
Editorial systems use weight sparingly. The display element is often set in
|
||||
a light or regular weight — hierarchy is carried by scale and space, not mass.
|
||||
|
||||
Bold in editorial context means: this word/phrase matters beyond the sentence.
|
||||
It is not used for section labels, UI chrome, or navigation. One to two bold
|
||||
phrases per 400 words of body copy is a working upper bound.
|
||||
|
||||
If everything important is bold, nothing is.
|
||||
|
||||
### 4. Display tracking
|
||||
|
||||
Negative tracking at large sizes is mandatory for Latin display. At editorial display sizes
|
||||
(56 px+), tracking should be `-0.02em` to `-0.05em` (editorial override;
|
||||
see `typography.md` §letter-spacing for the default range). Light display
|
||||
weights may go tighter within this range.
|
||||
|
||||
**Script-aware exception:** For Arabic, Persian, and Urdu (cursive-joining scripts),
|
||||
keep tracking at `0` — negative letter-spacing breaks cursive joining (see `rtl-and-bidi`).
|
||||
Hebrew uses logical spacing rules but is not cursive-joining; consult `rtl-and-bidi`
|
||||
for right-to-left baseline adjustments. Hierarchy in these scripts is carried by size,
|
||||
weight, and whitespace, not tracking.
|
||||
|
||||
---
|
||||
|
||||
### 5. Pull quotes as rhythm disruption
|
||||
|
||||
A pull quote is not a blockquote. It is a visual interrupt.
|
||||
|
||||
| Property | Behavior |
|
||||
|---|---|
|
||||
| Scale | 28–40 px — above body, below display |
|
||||
| Weight | Regular or light — never bold |
|
||||
| Tracking | Slightly negative (`-0.01em`) or zero |
|
||||
| Alignment | Break from body column — full width, or offset inline-start/inline-end |
|
||||
| Spacing | Large above and below — equal to or greater than a section break |
|
||||
| Color | May use `var(--accent)` as the only accent use on the page |
|
||||
|
||||
Pull quotes placed at regular intervals destroy their effect. One per
|
||||
long-form article is usually correct. Two is a maximum.
|
||||
|
||||
### 6. Body measure and reading rhythm
|
||||
|
||||
Long-form body copy: 60–70 ch line length. Tighter than the `typography.md`
|
||||
max because editorial reading is sustained, not scanning.
|
||||
|
||||
Line height: `1.6`–`1.7` for serif body. Slightly more generous than the
|
||||
universal rule because editorial bodies are set at a reading size, not a UI
|
||||
size.
|
||||
|
||||
Do not justify. Use `text-align: start` with a ragged inline-end edge — the
|
||||
natural setting for editorial body copy on screen.
|
||||
|
||||
### 7. Asymmetrical rhythm
|
||||
|
||||
Uniform section spacing reads as a template. Editorial pacing alternates
|
||||
between compression and expansion:
|
||||
|
||||
- Dense section → spacious section → medium section.
|
||||
- A tightly spaced image caption cluster immediately after a generous
|
||||
body paragraph creates productive tension.
|
||||
- The final section before a pull quote should tighten; after it, release.
|
||||
|
||||
---
|
||||
|
||||
## Editorial hierarchy table
|
||||
|
||||
| Element | Scale | Weight | Tracking | Leading | Spacing role |
|
||||
|---|---|---|---|---|---|
|
||||
| Display headline | 56–96 px | Light or regular | `-0.02em` to `-0.05em` | `1.0`–`1.1` | Event — generous above/below |
|
||||
| Deck / standfirst | 18–24 px | Regular | `0` | `1.4`–`1.5` | Transitional — moderate gap below |
|
||||
| Byline / dateline | 12–14 px | Regular or medium | `0.02em`–`0.04em` | `1.5` | Recedes — tight gap below |
|
||||
| Body | 16–18 px | Regular | `0` | `1.6`–`1.7` | Baseline — rhythm carrier |
|
||||
| Pull quote | 28–40 px | Regular or light | `-0.01em` (Latin only; 0 for joining scripts) | `1.2`–`1.3` | Interrupt — large above/below |
|
||||
| Image caption | 12–13 px | Regular | `0.01em` | `1.5` | Recedes — tight cluster |
|
||||
| Section label | 11–12 px | Medium | `0.06em`–`0.1em` (if caps) | `1.5` | Wayfinding only |
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Bold display headline** — editorial display is usually light or regular.
|
||||
Bold display reads as billboard advertising. If the design system's display
|
||||
weight is heavy, either use the regular cut or revisit the choice.
|
||||
- **Uniform section padding** — every section has the same gap. No pacing.
|
||||
The page reads as a list of content blocks.
|
||||
- **Pull quote styled as a callout box** — border-left, background tint,
|
||||
or card treatment. A pull quote is typographic. It does not need a container.
|
||||
- **Deck set at body size** — the gap between headline and deck must be large
|
||||
enough to read as a scale event. 18 px minimum for a deck below a 56 px+
|
||||
headline.
|
||||
- **Heading for every section** — editorial long-form does not require
|
||||
a heading at every content shift. Space and pacing are allowed to do the
|
||||
work. (guidance)
|
||||
- **Positive tracking on display** — wide-tracked display headlines read as
|
||||
a branding exercise. Tighten them.
|
||||
- **UI chrome in the reading column** — buttons, tags, chip badges interrupting
|
||||
prose flow inside the body text column. Functional controls (inline code-copy,
|
||||
API anchors, callout toggles) may live inside code and API blocks, but decorative
|
||||
badges and UI chrome should live outside the reading measure to keep prose focus clear.
|
||||
|
||||
---
|
||||
|
||||
## Lint
|
||||
|
||||
- [ ] Display headline is light or regular weight unless the design system
|
||||
specifies otherwise.
|
||||
- [ ] Display tracking is within `-0.02em` to `-0.05em` at 56 px+ (Latin only;
|
||||
0 for Arabic/Persian/Urdu joining scripts; see `rtl-and-bidi` for Hebrew). (guidance)
|
||||
- [ ] Pull-quote tracking: `-0.01em` (Latin only; 0 for joining scripts). (guidance)
|
||||
- [ ] Gap between display and deck: display/deck ratio ≥ 1.5× or absolute px delta
|
||||
≥ 24 px to read as a scale event. (guidance)
|
||||
- [ ] Body line height is `1.6`–`1.7`.
|
||||
- [ ] Body measure is 60–70 ch.
|
||||
- [ ] Pull quote, if present, breaks the body column (full width or offset).
|
||||
- [ ] No more than 2 pull quotes in a single article surface. (long-form only)
|
||||
- [ ] Section spacing alternates — one gap ≥ 1.5× baseline rhythm while another
|
||||
is ≤ 1.2×. (long-form only — guidance)
|
||||
- [ ] Bold used ≤ 2 times per 400 words in body copy. (long-form only)
|
||||
- [ ] `var(--accent)` used ≤ 2 times on the full editorial surface (see `color.md` §accent discipline).
|
||||
- [ ] Section separators (rules, dingbats) are used only when they
|
||||
reinforce publication identity or clearly mark unrelated-content boundaries;
|
||||
they must not be used as a hierarchy fallback. (guidance)
|
||||
- [ ] Pull quote has no background, border, or container treatment.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Typography hierarchy craft rules
|
||||
|
||||
Shared hierarchy contracts that layer on top of `typography.md`. This file does
|
||||
not repeat scale ranges or tracking values — those live in `typography.md`.
|
||||
This file defines how hierarchy *behaves*: entry points, rhythm, tension, and
|
||||
the conditions under which controlled violations are allowed. This contract
|
||||
applies per-surface (a page with multiple pacing resets may establish new
|
||||
primaries at intentional intervals), not globally.
|
||||
|
||||
> Opt in via `od.craft.requires: [typography, typography-hierarchy]`.
|
||||
> Aesthetic-specific variants (e.g. `typography-hierarchy-editorial`) extend this.
|
||||
|
||||
---
|
||||
|
||||
## The core contract
|
||||
|
||||
Every typographic surface must satisfy all three:
|
||||
|
||||
1. **One dominant entry point.** The eye needs a place to start. One element
|
||||
wins the hierarchy — not two, not three. If everything competes, nothing leads.
|
||||
2. **Intentional rhythm between levels.** Hierarchy is not a list of sizes.
|
||||
It is the *contrast* between them. Adjacent levels that are too close
|
||||
in scale, weight, or spacing produce a flat, undifferentiated surface.
|
||||
3. **Recoverable information flow.** Hierarchy may be inverted, collapsed,
|
||||
or disrupted — but a reader must still be able to reconstruct the content
|
||||
structure without re-reading. If they can't, it's chaos, not tension.
|
||||
|
||||
---
|
||||
|
||||
## Hierarchy vectors
|
||||
|
||||
Scale is one lever. Use all five.
|
||||
|
||||
| Vector | What it controls | Hierarchy direction |
|
||||
|---|---|---|
|
||||
| Scale | Size contrast between levels | Large → small reads as primary → secondary |
|
||||
| Weight | Mass contrast between levels | Heavier reads as primary (see Controlled violations for weight inversion) |
|
||||
| Spacing | Breathing room around an element | More space = more visual importance |
|
||||
| Tracking | Tension and velocity | Tighter = faster; wider = ceremonial, slower |
|
||||
| Alignment | Relationship to the grid/edge | Breaking alignment signals importance |
|
||||
|
||||
No single vector is required. A heading may lead through spacing alone if
|
||||
scale is deliberately suppressed. A pull quote may lead through alignment
|
||||
break. Identify which vectors are active and make sure at least two are
|
||||
working in the same direction for the dominant element.
|
||||
|
||||
---
|
||||
|
||||
## Semantic role ≠ visual role
|
||||
|
||||
Allowed. Not an error. Not a lint violation.
|
||||
|
||||
An `<h1>` may render visually quieter than a nearby `<p>` if the
|
||||
composition requires it. Body copy may behave like display typography.
|
||||
A label may visually outrank a heading.
|
||||
|
||||
**The condition:** information flow must remain intact. A user who reads
|
||||
linearly must still understand what is important, what supports it, and
|
||||
what is incidental — regardless of which element "wins" visually.
|
||||
|
||||
---
|
||||
|
||||
## Hierarchy rhythm — the two failure modes
|
||||
|
||||
### Flat hierarchy
|
||||
|
||||
Everything lands at roughly the same visual weight. The surface reads as
|
||||
a wall. Usually caused by:
|
||||
- Scale steps that are too close (e.g. 18 / 20 / 22 px for three levels)
|
||||
- Weight used only once (everything is regular, or everything is medium)
|
||||
- Uniform spacing between all elements
|
||||
|
||||
Fix: increase contrast between levels. Use at least two vectors simultaneously.
|
||||
|
||||
### Noise hierarchy
|
||||
|
||||
Too many elements fighting for dominance. Everything is bold, large, or
|
||||
accented. The eye has no resting point and no path.
|
||||
|
||||
Fix: promote one element deliberately. Demote everything else — including
|
||||
things that feel important. Hierarchy is relative, not absolute.
|
||||
|
||||
---
|
||||
|
||||
## Controlled violations
|
||||
|
||||
The following are explicitly allowed when the three core contracts are met:
|
||||
|
||||
| Violation | Allowed when |
|
||||
|---|---|
|
||||
| Body copy at display scale | It is the intended entry point and nothing else competes |
|
||||
| Heading rendered lighter than body | Intentional visual inversion with intact information flow |
|
||||
| Zero scale contrast between levels | Hierarchy is carried entirely by spacing or tracking |
|
||||
| No heading-level element visible | Hierarchy is emergent from layout/spacing alone |
|
||||
| Primary-level spacing applied to secondary element | Creates deliberate tension while maintaining information flow |
|
||||
|
||||
**"Information flow remains intact" safeguards:**
|
||||
- DOM/reading order still matches content meaning (no layout inversion breaks narrative)
|
||||
- Proximity groups the inverted element with its parent/context
|
||||
- Only one primary exists in the visual region (no competing co-primaries)
|
||||
- A quick scan can identify entry point / support / incidental roles without rereading
|
||||
|
||||
---
|
||||
|
||||
## Spacing as hierarchy
|
||||
|
||||
Spacing is a full hierarchy vector. A typographic level can be elevated
|
||||
entirely through surrounding whitespace without changing its size or weight.
|
||||
|
||||
Rules:
|
||||
- Space above an element signals its relationship to what came before.
|
||||
- Space below an element signals its relationship to what follows.
|
||||
- An isolated element with large surrounding space reads as display-level
|
||||
regardless of its font size.
|
||||
- Uniform spacing between all elements destroys spatial hierarchy.
|
||||
|
||||
---
|
||||
|
||||
## Three-level working model
|
||||
|
||||
Most surfaces can be mapped to three functional levels:
|
||||
|
||||
| Level | Role | Typical vectors |
|
||||
|---|---|---|
|
||||
| **Primary** | Entry point. One at a time per visual region; long-form surfaces may re-establish at intentional pacing resets. | Scale, spacing, or alignment break |
|
||||
| **Secondary** | Structure. Subdivides or supports primary. | Weight, scale step, or tracking shift |
|
||||
| **Tertiary** | Incidental. Labels, captions, metadata. | Scale reduction, weight reduction, or positive tracking |
|
||||
|
||||
More than three visible levels above the fold is usually a composition problem,
|
||||
not a hierarchy opportunity. Collapse or demote before adding a fourth level.
|
||||
|
||||
**Long-form surfaces:** May re-establish a primary at intentional pacing resets
|
||||
(e.g. a new section with its own headline and breathing room). Never maintain
|
||||
two simultaneous primaries within the same visual region.
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Graduated weight ladder** — regular → medium → semibold → bold → extrabold,
|
||||
each level one step heavier. Reads as a default scale, not authored hierarchy.
|
||||
Weight should jump, not step.
|
||||
- **Uniform section spacing** — every section gap is the same value. No
|
||||
hierarchy information is carried by spacing. Vary it deliberately.
|
||||
- **Heading as the only hierarchy vector** — the heading is large and bold;
|
||||
everything else is flat. The heading does all the work. This is a sign
|
||||
that spacing and tracking are not being used as vectors.
|
||||
- **Symmetrical emphasis** — two elements receive equal visual weight as
|
||||
co-primaries. Pick one. The other becomes secondary.
|
||||
- **Size-only hierarchy** — all contrast is in font size alone. Weight,
|
||||
spacing, tracking, and alignment are uniform across levels. Fragile —
|
||||
any layout constraint that collapses the size contrast destroys the hierarchy.
|
||||
|
||||
---
|
||||
|
||||
## Lint
|
||||
|
||||
- [ ] One element is unambiguously dominant above the fold.
|
||||
- [ ] At least two hierarchy vectors are active on the dominant element.
|
||||
- [ ] No two adjacent levels share the same scale, weight, AND spacing.
|
||||
- [ ] Spacing between levels varies — at least one gap is ≥1.5× the others or
|
||||
represents one typographic scale step (e.g. one token unit like `gap-md` vs `gap-sm`). (guidance)
|
||||
- [ ] Semantic/visual role inversions remain structurally readable.
|
||||
- [ ] Flat hierarchy: scale steps between levels are ≥1.25× apart OR compensated by a weight or spacing jump. (guidance)
|
||||
- [ ] Noise hierarchy: no more than one element reads as primary above the fold.
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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 OpenDesign'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` |
|
||||
|
||||
### CJK overrides — these are not optional
|
||||
|
||||
The table above is Latin leading. Latin display type can go to `1.0`
|
||||
because the ascender/descender slack inside the em box keeps lines
|
||||
apart. **CJK glyphs fill the em box**, so the same value makes
|
||||
consecutive lines touch, and multi-line Chinese headlines visibly
|
||||
collide.
|
||||
|
||||
| Text size | Latin | CJK |
|
||||
|---|---|---|
|
||||
| Display / H1 (≥32 px) | `1.0`–`1.2` | **`1.3`–`1.4`** |
|
||||
| Body (15–18 px) | `1.5`–`1.6` | `1.7`–`1.8` |
|
||||
|
||||
The CJK floor has **no upper size tier**. It applies to every heading
|
||||
level, including the **cover / hero main title** — the single biggest
|
||||
headline on a deck's first slide. A Chinese cover title at 72 px,
|
||||
96 px, or larger, especially one split into multiple lines with
|
||||
`<br>`, is still CJK display text: set `line-height: 1.3`–`1.4` on
|
||||
it, never the Latin `1.0`–`1.2`. At 96 px a `1.05` leading makes the
|
||||
two lines of a Chinese cover headline visibly collide. This is the
|
||||
single most common violation, so check the cover main title first.
|
||||
|
||||
Negative tracking is Latin-only for the same reason: CJK is already
|
||||
set on a fixed em grid, so `-0.02em` on a Chinese headline crowds the
|
||||
glyphs instead of tightening the word. Use `0` for CJK display text.
|
||||
|
||||
When one artifact mixes both — an English kicker over a Chinese
|
||||
headline is the common case — set the tight Latin values on the Latin
|
||||
element only. Do not inherit them onto the CJK block from a shared
|
||||
parent rule.
|
||||
|
||||
## 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 (Latin only — see the CJK overrides).
|
||||
- CJK display text at Latin leading (`≤1.2`), which makes the lines overlap.
|
||||
- Multi-line CJK cover / hero titles (72–96 px+, e.g. split with `<br>`) below `1.3` line-height.
|
||||
- Negative tracking applied to CJK text.
|
||||
- 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