feat(skills): import core skills from .agents catalog
- Add junior-to-senior with research playbook and review rubric - Add fuck-slop with tell catalog and multi-register voice profiles - Add interface-kit with design engineering, motion, and a11y references - Add app-security with 4-pillar upload defense and hardening playbooks - Add debugging-workflow with systematic root cause isolation - Standardize all skills to 5-phase Skill Architect protocol - Sync symlinks to OpenCode, Codex, and Claude Code
This commit is contained in:
1 parent
3a92d9fde5
commit
4fa17b8086
15 files changed
+3308
No files matched your search
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: app-security
|
||||
description: >
|
||||
Full-stack application and API security hardening: Four-Pillar upload defense,
|
||||
SSRF prevention, input boundary clamping, IDOR elimination, and secret masking.
|
||||
Use when creating or auditing API routes, handling uploads, making outbound requests,
|
||||
or preparing pre-release security verification.
|
||||
concerns: [security]
|
||||
---
|
||||
|
||||
# Application Security Hardening
|
||||
|
||||
Enforces defensive boundaries across the API membrane, eliminating exploit vectors, path traversals, SSRF risks, IDOR vulnerabilities, and payload exhaustion.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Inbound Request"] --> B["1. Membrane Clamping<br/>(Schema & Bound Validation)"]
|
||||
B --> C["2. Four-Pillar Uploads<br/>(Size, Ext, Magic, Path)"]
|
||||
C --> D["3. Outbound SSRF Guard<br/>(Loopback/Metadata Block)"]
|
||||
D --> E["4. Secret Masking<br/>(Zero Token Sediment)"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Setup and Protocols
|
||||
|
||||
1. Review reference playbooks:
|
||||
- [references/app-hardening-playbook.md](references/app-hardening-playbook.md) for concrete TypeScript/Python code patterns and boundary clamps.
|
||||
- [references/review-checklist.md](references/review-checklist.md) for pre-release OWASP verification checks.
|
||||
2. Invariants:
|
||||
- Every input payload must be typed and bounded.
|
||||
- Every state mutation must verify session identity and tenant ownership.
|
||||
- Outbound HTTP fetches must never touch private/loopback networks.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Ingest and Check
|
||||
|
||||
1. **Surface Identification**:
|
||||
- Inbound API routes (REST, GraphQL, RPC handlers).
|
||||
- File/document ingestion and attachment endpoints.
|
||||
- Outbound network calls (webhooks, URL unfurlers, external data scrapers).
|
||||
- Data persistence operations and logging sinks.
|
||||
2. **Access Control Check**:
|
||||
- Confirm identity is derived from verified server sessions/tokens, never trusted client parameters (`userId`, `orgId`).
|
||||
- Confirm entity queries enforce ownership: verify record exists AND belongs to the authenticated caller before mutating.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Processing (Core Logic)
|
||||
|
||||
### Step 1: Input Boundaries & Membrane Clamping
|
||||
- Never accept arbitrary, unvalidated request dictionaries. Model all payloads using strict schemas (e.g. Pydantic, Zod).
|
||||
- Enforce hard string limits:
|
||||
- Text prompts / message content: Max 50,000 characters.
|
||||
- Titles / Names: Max 100–200 characters.
|
||||
- Pagination limits: Clamped to `1 <= limit <= 50`.
|
||||
- Batch arrays: Clamped to max 100–200 items.
|
||||
- Sanitize entity and path identifiers against path traversal tokens (`../`, `..\\`).
|
||||
|
||||
### Step 2: Four-Pillar Upload Defense
|
||||
When handling file or media uploads:
|
||||
1. **Size Bounds**: Reject payloads exceeding size thresholds (e.g., 100MB for docs; 5MB for images).
|
||||
2. **Blocked Extensions**: Reject executable and script formats (`.exe`, `.bat`, `.sh`, `.svg`, `.html`, `.php`).
|
||||
3. **Magic Byte Inspection**: Inspect file headers to detect disguised binaries (PE `MZ`, ELF, Mach-O).
|
||||
4. **Path Traversal Defense**:
|
||||
- Sanitize original filenames (strip path separators and non-printable characters).
|
||||
- Write files strictly to designated, isolated upload roots with unique IDs (e.g. UUID filenames).
|
||||
|
||||
### Step 3: Outbound SSRF Guard
|
||||
Before executing outbound HTTP requests (scraping, webhooks):
|
||||
- Validate target URLs against an allowlist or safety filter.
|
||||
- Block loopback addresses (`127.0.0.1`, `localhost`, `::1`), RFC 1918 private subnets (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), and cloud metadata services (`169.254.169.254`).
|
||||
- Restrict allowed protocols strictly to `http` and `https`.
|
||||
|
||||
### Step 4: Secret Masking & Error Shielding
|
||||
- Scrub Authorization tokens (`Bearer ***`), provider API keys, and sensitive credentials from logs, database traces, and telemetry.
|
||||
- In public HTTP error responses, return clean structured error objects with opaque error codes; never leak internal filesystem paths, environment variables, or stack traces.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: The System One Balance Pass
|
||||
|
||||
- **Augment, Don't Handcuff**:
|
||||
- Do NOT break local development workflows: allow local mock endpoints and fixture testing in non-production test environments.
|
||||
- Keep security checks non-intrusive during prototyping; enforce strictly at boundaries and pre-release gates.
|
||||
- Avoid noisy confirmation warnings for routine safe operations.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Output Execution & Verification
|
||||
|
||||
1. Run verification checks from [references/review-checklist.md](references/review-checklist.md).
|
||||
2. Provide a security verification summary detailing:
|
||||
- Bound clamps applied to route schemas.
|
||||
- Upload filters active (size, extension, magic bytes, safe path).
|
||||
- SSRF protection status on outbound calls.
|
||||
- Secret scrubbing confirmation.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Full-Stack Application Security Hardening Playbook
|
||||
|
||||
This high-level guide provides a reusable, standardized protocol for auditing and securing full-stack applications (Next.js, Node.js, Express, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Endpoint & Access Control (Preventing IDOR)
|
||||
|
||||
### 1. The Multi-Tenant Ownership Rule
|
||||
Every state-changing API request (`POST`, `PUT`, `PATCH`, `DELETE`) and private resource read (`GET`) must establish identity via the server session—never by trusting client parameters.
|
||||
|
||||
#### Never Trust Client User IDs:
|
||||
```typescript
|
||||
// ❌ Vulnerable (attacker can pass any userId in body)
|
||||
const { title, userId } = await req.json();
|
||||
await db.resource.create({ data: { title, userId } });
|
||||
|
||||
// ✅ Secure (always bind to verified session)
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) return unauthorized();
|
||||
await db.resource.create({ data: { title, userId: session.user.id } });
|
||||
```
|
||||
|
||||
#### Verify Resource Ownership Before Mutation:
|
||||
```typescript
|
||||
// Verify target record exists AND belongs to the caller
|
||||
const item = await db.resource.findUnique({ where: { id } });
|
||||
if (!item) return notFound();
|
||||
if (item.userId !== session.user.id) return forbidden();
|
||||
```
|
||||
|
||||
#### Lock Down Sensitive Data Endpoints:
|
||||
* Strip internal data (emails, password hashes, roles, permissions) from public query endpoints.
|
||||
* Never allow query flags like `?includePrivate=true` unless the caller has verified ownership or administrative authorization for that account.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Input Boundaries & Payload Sanitization
|
||||
|
||||
### 1. Enforce Hard Limits on Every Field
|
||||
Unbounded input strings can cause database exhaustion, buffer overruns, or CPU denial-of-service (e.g., Bcrypt hashing huge strings).
|
||||
|
||||
| Field Type | Recommended Hard Bound | Rationale |
|
||||
| :--- | :--- | :--- |
|
||||
| **Passwords** | Min 8 chars, Max 72–100 chars | Bcrypt truncates or burns excessive CPU beyond 72 bytes |
|
||||
| **Emails** | Max 254 chars | Standard RFC 5321 length; prevents regex DoS |
|
||||
| **Titles / Names** | Max 100–200 chars | Prevents UI overflow and DB memory bloat |
|
||||
| **Descriptions / Content** | Max 2,000–50,000 chars | Prevents payload bombing |
|
||||
| **Pagination limit** | Clamped to `1 <= limit <= 50` | Prevents querying the entire table at once |
|
||||
|
||||
### 2. JSON & Batch Array Clamping
|
||||
When accepting array batches (e.g., bulk updating nodes or items), enforce an explicit upper limit (e.g., max 200–500 items per request).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Safe File & Document Upload Architecture
|
||||
|
||||
If an application accepts images, PDFs, or documents, implement the **Four-Pillar Upload Defense**:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["File Upload"] --> B["1. Size Check"]
|
||||
B --> C["2. Extension & MIME Allowlist"]
|
||||
C --> D["3. Magic Bytes Inspection"]
|
||||
D --> E["4. Filename Sanitization & Storage"]
|
||||
```
|
||||
|
||||
### 1. Size Caps:
|
||||
* Check file size before buffering: images (max 2–5MB), documents (max 10MB).
|
||||
* Terminate stream early if incoming chunks exceed the configured byte threshold.
|
||||
|
||||
### 2. Strict MIME & Extension Allowlists:
|
||||
* **Allowed:** `image/jpeg` (`.jpg`, `.jpeg`), `image/png` (`.png`), `image/webp` (`.webp`), `application/pdf` (`.pdf`), `application/json` (`.json`), `text/markdown` (`.md`).
|
||||
* **Strictly Blocked:** `.exe`, `.bat`, `.sh`, `.php`, `.js`, `.html`, `.svg` (SVGs can embed `<script>` tags and execute XSS unless explicitly sanitized).
|
||||
|
||||
### 3. Magic Bytes (Header Signature) Inspection:
|
||||
Do not trust the client `Content-Type` header or file extension alone. Inspect the initial bytes of the file buffer:
|
||||
|
||||
| Format | Magic Bytes Signature | Hex Representation |
|
||||
| :--- | :--- | :--- |
|
||||
| **JPEG** | `\xFF\xD8\xFF` | `FF D8 FF` |
|
||||
| **PNG** | `\x89PNG\r\n\x1a\n` | `89 50 4E 47` |
|
||||
| **WebP** | `RIFF....WEBP` | `52 49 46 46` ('RIFF') |
|
||||
| **PDF** | `%PDF-` | `25 50 44 46` ('%PDF') |
|
||||
|
||||
### 4. Filename Sanitization & Non-Executable Storage:
|
||||
* Strip directory traversal sequences (`../`, `..\\`) and null bytes (`\0`).
|
||||
* Store files using randomized UUIDs/CUIDs (e.g., `clx987654321.png`) rather than the original user-supplied name.
|
||||
* Serve uploaded files with `Content-Disposition: attachment` or host from dedicated static object storage (S3/GCS) where server script execution is disabled.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Rate Limiting & Abuse Prevention
|
||||
|
||||
### Protect Sensitive Action Gates:
|
||||
* **Login attempts:** Max 5–10 per 15 minutes per IP.
|
||||
* **User registrations:** Max 3–5 per hour per IP.
|
||||
* **Password resets:** Max 3 per hour per email/IP.
|
||||
* **General API read/write:** 60–120 requests per minute per authenticated user / IP.
|
||||
|
||||
### Implementation Strategy:
|
||||
* **Single Server / Microservice:** Use an in-memory sliding-window bucket with automatic timestamp cleanup.
|
||||
* **Distributed / Serverless:** Use Redis (e.g., Upstash / Redis token bucket) to share rate limits across multiple instances.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Framework Hardening & HTTP Security Headers
|
||||
|
||||
### 1. Disable Fingerprinting
|
||||
* In `next.config.ts` / `next.config.js`:
|
||||
```typescript
|
||||
const nextConfig = {
|
||||
poweredByHeader: false,
|
||||
};
|
||||
```
|
||||
* In Express:
|
||||
```typescript
|
||||
app.disable('x-powered-by');
|
||||
```
|
||||
|
||||
### 2. Standard Security Headers
|
||||
Inject these headers across all routes (via Next.js `headers()` config, middleware, or reverse proxy):
|
||||
|
||||
```http
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Continuous Dependency & Build Verification
|
||||
|
||||
Establish a 4-step security check before every release:
|
||||
|
||||
### 1. Vulnerability Audit:
|
||||
```bash
|
||||
npm audit
|
||||
# Apply automated non-breaking security fixes:
|
||||
npm audit fix
|
||||
```
|
||||
|
||||
### 2. Static Code & Lint Analysis:
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### 3. Automated Security Unit Tests:
|
||||
Maintain unit tests validating that:
|
||||
* File upload allowlists reject `.exe`, `.html`, `.svg`, and mismatched magic bytes.
|
||||
* Rate limiters trigger HTTP 429 when thresholds are exceeded.
|
||||
* Boundary helpers clamp oversized numbers and truncate/reject excessive payload strings.
|
||||
* Endpoints reject unauthenticated mutations and IDOR resource manipulation.
|
||||
|
||||
### 4. Production Build Verification:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
# Security Review & OWASP Verification Checklist
|
||||
|
||||
## Input Validation & Boundaries
|
||||
- [ ] Pydantic schemas enforce hard string bounds (max 50,000 for text, max 200 for titles).
|
||||
- [ ] Dynamic path/query identifiers validated via `sanitize_identifier()`.
|
||||
- [ ] Pagination limits clamped to `1 <= limit <= 50`.
|
||||
- [ ] Batch array inputs clamped to safe upper bounds (max 100–200 items).
|
||||
|
||||
## Four-Pillar Upload Defense
|
||||
- [ ] Uploads restricted by `DEFAULT_MAX_FILE_SIZE` (100MB; 5MB for images).
|
||||
- [ ] Blocked executable extensions rejected (`BLOCKED_EXTENSIONS`).
|
||||
- [ ] Extension checked against `ALLOWED_EXTENSIONS`.
|
||||
- [ ] Header magic bytes inspected (`check_magic_bytes`) to block disguised binaries.
|
||||
- [ ] Filenames sanitized with `sanitize_filename()` (path traversal and CLI injection stripped).
|
||||
- [ ] Disk writes resolved strictly via `safe_resolve_path()`.
|
||||
|
||||
## SSRF Defense
|
||||
- [ ] All outbound requests in research/web-retrieval validated with `validate_safe_url()`.
|
||||
- [ ] Loopback (`127.0.0.1`), private RFC 1918 subnets, and cloud metadata (`169.254.169.254`) blocked.
|
||||
- [ ] Scheme restricted strictly to `http` and `https`.
|
||||
|
||||
## Secrets & Data Hygiene
|
||||
- [ ] Zero API keys, bearer tokens, or password hashes written to `error_log` or logs.
|
||||
- [ ] Authorization headers masked in logging formatters (`Bearer ***`).
|
||||
- [ ] Production error responses mask internal database paths and raw tracebacks.
|
||||
|
||||
## Authorization & State Safety
|
||||
- [ ] Mutations verify that target records match the active session / conversation scope.
|
||||
- [ ] SQLite queries use parameterized SQL; no raw string concatenation.
|
||||
- [ ] Rate limits applied on expensive inference and digestion endpoints.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: debugging-workflow
|
||||
description: >
|
||||
Systematic, hypothesis-driven debugging workflow: reproduction, isolation, root cause identification,
|
||||
surgical fixes, and regression prevention. Use when diagnosing bugs, unexpected runtime errors,
|
||||
failing tests, or when asked to debug an issue.
|
||||
concerns: [system-one-balance]
|
||||
---
|
||||
|
||||
# Debugging Workflow
|
||||
|
||||
A systematic, disciplined protocol for finding and fixing software bugs efficiently without guessing or introducing collateral regressions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Setup and Protocols
|
||||
|
||||
1. Invariants:
|
||||
- Never apply random fixes hoping one works. Every fix must be preceded by an identified root cause.
|
||||
- Prefer minimal surgical edits over wide refactors during bug resolution.
|
||||
- Every resolved bug should ideally be pinned with a reproduction test.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Ingest and Check
|
||||
|
||||
1. **Information Gathering**:
|
||||
- Inspect the verbatim error message, stack trace, and relevant log streams.
|
||||
- Identify the exact file and line where failure manifests.
|
||||
- Record the expected vs. actual behavior and environmental conditions.
|
||||
2. **Reproduction & Isolation**:
|
||||
- Determine whether the bug is reliably reproducible.
|
||||
- Construct a minimal reproduction case or failing test.
|
||||
- Identify recent changes: review `git diff HEAD~5..HEAD` or recent commits if the issue is a regression.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Processing (Core Logic)
|
||||
|
||||
### Step 1: Formulate and Rank Hypotheses
|
||||
Classify the issue into primary failure categories:
|
||||
- **Logic Errors**: Off-by-one, inversion of boolean operators, missing edge cases.
|
||||
- **Data Errors**: Null/undefined references, schema divergence, malformed encodings.
|
||||
- **State & Concurrency**: Race conditions, stale cache, incomplete state mutation.
|
||||
- **Integration Errors**: API contract drift, network timeouts, environment configuration.
|
||||
|
||||
Formulate the most probable hypothesis and design a quick diagnostic probe to validate or falsify it.
|
||||
|
||||
### Step 2: Diagnostic Probing
|
||||
- Add focused diagnostic logging or evaluate intermediate states.
|
||||
- Use binary search / bisection when isolating faults in large code paths.
|
||||
- Check assumptions at subsystem boundaries: inspect inputs immediately before and outputs immediately after the failure site.
|
||||
|
||||
### Step 3: Implement Minimal Surgical Fix
|
||||
- Address the underlying defect rather than masking the symptom (e.g. don't simply wrap with a silent `try/catch` or null check if an invariant was violated upstream).
|
||||
- Keep changes minimal and localized to the responsible subsystem.
|
||||
|
||||
### Step 4: Regression Defense
|
||||
- Execute test suites to confirm no existing functionality broke.
|
||||
- Add or update an automated unit/integration test capturing the failure condition.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: The System One Balance Pass
|
||||
|
||||
- **Augment, Don't Handcuff**:
|
||||
- If full stack trace and error context are already visible in the conversation, do NOT ask the user redundant intake questions ("Can you provide the logs?"). Proceed directly to diagnosis.
|
||||
- Do NOT rewrite unrelated modules or refactor working code while fixing a bug.
|
||||
- Fix fast; reserve deep architectural audits for dedicated refactoring passes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Output Execution & Verification
|
||||
|
||||
Deliver the debugging report with:
|
||||
1. **Root Cause**: Crisp explanation of why the failure occurred.
|
||||
2. **Surgical Diff**: The exact code changes applied.
|
||||
3. **Verification**: Command executed or test output demonstrating the fix.
|
||||
4. **Prevention**: Regression test added to guard against recurrence.
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: fuck-slop
|
||||
description: >
|
||||
De-slop pass for any text: detects and erases statistical AI writing fingerprints
|
||||
(negative parallelisms, em-dash abuse, rule-of-three, false ranges, puffery, uniform cadence)
|
||||
and rewrites prose to match target genre registers. Use when asked to "fuck slop", "deslop",
|
||||
"remove AI tells", "humanize", or before publishing agent-drafted prose.
|
||||
concerns: [style]
|
||||
---
|
||||
|
||||
# F*ck Slop
|
||||
|
||||
Strip every mark of AI writing from a text and make it authentic to its genre. Not "make it pass an AI detector" — make it read as if a specific human with a specific point wrote it for a specific audience.
|
||||
|
||||
## The Core Problem
|
||||
|
||||
The worst tells — especially **"not X but Y"** negative parallelisms — are emergent properties of preference tuning rewarding contrastive, comprehensive-sounding framing.
|
||||
1. **You cannot reliably see your own slop**: Detection must be mechanical (regex against a fixed catalog), never subjective impressions.
|
||||
2. **Rewriting reintroduces slop**: Rewriting "it's not just X, it's Y" often outputs "this is less about X than Y" — the same tell in a wig. The scan must loop until clean.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Setup and Protocols
|
||||
|
||||
1. Review governing catalogs:
|
||||
- [references/tells.md](references/tells.md) for runnable `grep -Ein` patterns across all diagnostic categories.
|
||||
- [references/voices.md](references/voices.md) for target register profiles (academic, tweet, reddit, email, blog, docs, marketing).
|
||||
2. Rule: Never fix a pattern by paraphrasing the pattern. Fix by asking what the sentence asserts, then asserting that directly.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Ingest and Check
|
||||
|
||||
1. **Establish the Target**:
|
||||
- **Genre & Venue**: Academic article, technical documentation, blog, tweet, email, Reddit post.
|
||||
- **Audience & Stance**: Who reads it, and what does the author specifically claim? Slop fills the space where a claim should be.
|
||||
- **Constraints**: Word limits, house style, required terminology.
|
||||
2. **Mechanical Scan**:
|
||||
- Run the regex catalog from [references/tells.md](references/tells.md) against the text.
|
||||
- Flag structural rhythm failures:
|
||||
- **Cadence**: 3+ consecutive sentences within $\pm 4$ words of equal length.
|
||||
- **Formatting**: Bold scattering through prose, emoji bullet spam, symmetrical triads.
|
||||
- Summarize findings into a concise diagnostic list before rewriting.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Processing (Core Logic)
|
||||
|
||||
### Step 1: Negative Parallelism Triage
|
||||
Every "not X but Y" pattern receives one of three treatments:
|
||||
1. **Negation is a strawman** (nobody believes X): Delete X entirely; state Y directly with concrete evidence.
|
||||
2. **Contrast is genuine** (people hold X): Name who holds X and show concretely why Y wins.
|
||||
3. **Sentence asserts nothing** (decoration over empty claim): Delete the entire sentence.
|
||||
|
||||
*Banned escape hatches*: "less about X than Y", "X matters, but Y matters more", "the question isn't X, it's Y".
|
||||
|
||||
### Step 2: Vocabulary & Structure De-Slopping
|
||||
- **Puffery Tokens**: Replace inflated words (`pivotal`, `seismic`, `testament`, `tapestry`, `delve`, `multifaceted`) with plain verbs or concrete metrics.
|
||||
- **Rule-of-Three Lists**: Keep the single strongest item and discard filler. If keeping all three, break the symmetrical syntax.
|
||||
- **False Ranges**: ("from X to Y"): If no meaningful midpoint exists, name the two separate entities or cut one.
|
||||
- **Hedged Both-Sidesing**: ("it is worth noting", auto-counterpoints): Commit to a clear stance. Concede counterpoints only if genuine.
|
||||
- **Cadence Variance**: Mix short punchy sentences (3–6 words) with compound analytical statements. Break metronomic rhythm.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: The System One Balance Pass
|
||||
|
||||
- **Preserve Ground Truth**:
|
||||
- Do NOT alter author claims, empirical numbers, code identifiers, or citations. This is a stylistic de-slop pass, not content censorship.
|
||||
- **Avoid Overcorrection**:
|
||||
- Do NOT inject fake typos, manufactured slang, or artificial cynicism ("humanizer" slop).
|
||||
- Em dashes are permitted when used naturally (maximum one per 150 words; never two in a sentence).
|
||||
- In technical and academic registers, prioritize clarity and precision over informal attitude.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Output Execution & Verification
|
||||
|
||||
1. **Verification Loop**:
|
||||
- Re-run the Phase 1 scan on the rewritten text.
|
||||
- Fix and re-scan until zero regex hits occur (maximum 4 passes; if a sentence fails 4 passes, discard and rewrite its bare assertion from scratch).
|
||||
2. **Register Verification**:
|
||||
- Verify formatting and tone match [references/voices.md](references/voices.md).
|
||||
3. **Delivery**:
|
||||
- Output the clean, de-slopped text.
|
||||
- Provide a concise changelog table: Tell category, occurrences caught, and passes required.
|
||||
@@ -0,0 +1,171 @@
|
||||
# AI-Writing Tell Catalog
|
||||
|
||||
Detection patterns for the F*ck Slop scan. Patterns are written for `grep -Ein` (extended regex, case-insensitive, line numbers) so they can be run literally against a file:
|
||||
|
||||
```bash
|
||||
grep -Ein -f /dev/stdin draft.txt <<'PATTERNS'
|
||||
<paste patterns from a section below, one per line>
|
||||
PATTERNS
|
||||
```
|
||||
|
||||
When the text only exists in conversation, apply each pattern by hand. A match is a *finding*, not an automatic deletion — every finding goes through the Phase 2 triage in SKILL.md. Density matters: one em dash is nothing; one em dash plus a negative parallelism plus "delve" in the same paragraph is a verdict.
|
||||
|
||||
## 1. Negative parallelism — the "not X but Y" family
|
||||
|
||||
The highest-priority category. LLMs reach for the negation-then-assertion move roughly once a paragraph; humans use it occasionally and deliberately. It is an emergent generative habit, so expect it to reappear in paraphrased form after every rewrite pass — that is why the scan loops.
|
||||
|
||||
```
|
||||
not (just|only|merely|simply|solely) [^.;]{2,80}(but|it'?s| — )
|
||||
isn'?t (just|only|merely|simply|about)
|
||||
it'?s not (a|an|the|that|about|just) [^.;]{2,80}(it'?s|but)
|
||||
(is|was|are|were)n'?t about [^.;]{2,60}\. (it|this|that)'?s about
|
||||
less about [^.;]{2,60}(than|and more about)
|
||||
more than (just|a mere|simply)
|
||||
not because [^.;]{2,80}but because
|
||||
the (question|point|issue|problem|goal|real [a-z]+) is(n'?t| not) (whether|about|just|if)
|
||||
(doesn'?t|don'?t|didn'?t|won'?t) (just|merely|simply) [^.;]{2,80}(it|they|he|she|we)
|
||||
no [a-z]+, no [a-z]+(, no [a-z]+)?[,.]? just
|
||||
— not [^—.;]{2,60}, but
|
||||
not only [^.;]{2,80}but (also )?
|
||||
we'?re not (just )?(talking about|looking at|dealing with)
|
||||
gone are the days
|
||||
(here|this)'?s the (thing|kicker|catch|twist)
|
||||
```
|
||||
|
||||
Rhetorical-question variant (regex-resistant; check by hand): a one-line question immediately answered by a one-word or one-clause sentence. *"The result? Chaos."* / *"Sound familiar?"*
|
||||
|
||||
## 2. Puffery and inflated vocabulary
|
||||
|
||||
Single words that spike in LLM output. Each is fine in isolation; two or more per page is a finding. The fix is the plain word or the concrete fact the word was hiding.
|
||||
|
||||
```
|
||||
\b(delve|delving)\b
|
||||
\btapestry\b
|
||||
\b(testament|stands as)\b
|
||||
\bseamless(ly)?\b
|
||||
\b(pivotal|paramount|crucial)\b
|
||||
\bunderscore(s|d)?\b
|
||||
\b(landscape|realm|sphere) of\b
|
||||
\bnavigat(e|ing) the\b
|
||||
\bfoster(s|ing)?\b
|
||||
\bleverage(s|d)?\b
|
||||
\bmeticulous(ly)?\b
|
||||
\bintricate\b
|
||||
\bboasts\b
|
||||
\bgame.?chang(er|ing)\b
|
||||
\b(seismic|monumental|transformative) (shift|change)\b
|
||||
\bunwavering\b
|
||||
\bcommendable\b
|
||||
\belevate(s|d)? (the|your)\b
|
||||
\bshowcas(e|es|ing)\b
|
||||
\bresonate(s|d)?\b
|
||||
\bcompelling\b
|
||||
\brich (cultural )?(heritage|history|tradition)\b
|
||||
\bvibrant\b
|
||||
\bplays? a (vital|key|crucial|pivotal) role\b
|
||||
\bdeep(er)? dive\b
|
||||
\bunlock(s|ing)? (the|your)\b
|
||||
\bharness(es|ing)? the\b
|
||||
\bembark(s|ed|ing)? on\b
|
||||
\bever.?(evolving|changing)\b
|
||||
\bfast.?paced (world|environment)\b
|
||||
\bin today'?s\b
|
||||
\bat the end of the day\b
|
||||
\bwhen it comes to\b
|
||||
\bcutting.?edge\b
|
||||
\brobust\b
|
||||
\bholistic\b
|
||||
\bsynergy\b
|
||||
\bempower(s|ing|ment)?\b
|
||||
```
|
||||
|
||||
## 3. Hedging, both-sidesing, throat-clearing
|
||||
|
||||
The tell is reflexive balance: every claim gets a softener, every opinion gets a counterpoint. Commit or cut.
|
||||
|
||||
```
|
||||
it'?s (worth|important) (to note|noting|to remember|to consider)
|
||||
(that|it) (being )?said,
|
||||
while (it'?s|this is) (true|important)
|
||||
arguably
|
||||
in many ways
|
||||
to some (extent|degree)
|
||||
on the other hand
|
||||
at its core
|
||||
in essence
|
||||
essentially,
|
||||
ultimately,
|
||||
in conclusion
|
||||
in summary
|
||||
to sum(marize| up)
|
||||
overall,
|
||||
in the end,
|
||||
needless to say
|
||||
as (we|you) (can see|know|all know)
|
||||
let'?s (dive|unpack|explore|take a (look|closer look))
|
||||
whether you('re| are) [^.;]{2,60} or
|
||||
```
|
||||
|
||||
## 4. False ranges and rule-of-three
|
||||
|
||||
**False range** — a "from X to Y" with no actual spectrum between X and Y:
|
||||
|
||||
```
|
||||
from [^.;]{3,50} to [^.;]{3,50}
|
||||
```
|
||||
|
||||
Triage by hand: if you can name a meaningful midpoint, it's a real range and stays. If X and Y are just two loosely related examples, name them plainly or cut one.
|
||||
|
||||
**Rule of three** — LLMs default to triplets to make thin analysis look thorough. Regex only catches the simplest shape; check lists by hand too.
|
||||
|
||||
```
|
||||
\b\w+, \w+, and \w+[.!?]
|
||||
\b(\w+ \w+), (\w+ \w+), and (\w+ \w+)
|
||||
```
|
||||
|
||||
Triage: keep the strongest item, cut the rest — or keep all three only if each carries distinct information, and then break the rhythm.
|
||||
|
||||
## 5. Punctuation and formatting
|
||||
|
||||
Em dash: not banned — humans use it. Findings are about **density** and the contrast move:
|
||||
|
||||
- More than ~1 em dash per 150 words.
|
||||
- Two em dashes in one sentence.
|
||||
- `— not X, but Y` (already in section 1).
|
||||
- Em dash used for punchy emphasis where a comma works: `[a-z] — [a-z][^—]{1,25}\.$`
|
||||
|
||||
Other formatting tells (check by hand; most regexes here are layout-dependent):
|
||||
|
||||
- **Bold scattered through prose** like a textbook highlighting itself: `\*\*[^*]{2,40}\*\*` appearing more than ~once per 3 paragraphs of body prose.
|
||||
- **"Term: definition" bullets**: `^[-*] +\*\*[^*]+:?\*\*:? ` — the signature LLM list shape.
|
||||
- **Emoji headers/bullets** (🚀, ✅, 💡): needs PCRE, not `-E` — `LC_ALL=C.UTF-8 grep -Pn '^\s*[-*#]+\s.*[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}]' draft.txt`.
|
||||
- **Headers on short texts** — section headers on anything under ~400 words.
|
||||
- **The tidy skeleton** — intro that previews three points, three matched sections, conclusion that restates them. Resolves too neatly; real writing has loose ends.
|
||||
- **Numbered lists where a paragraph would do.**
|
||||
- Curly quotes/apostrophes in a context where the author types straight ones (mixed within one text is the stronger tell).
|
||||
|
||||
## 6. Cadence and statistical shape
|
||||
|
||||
No regex; measure or eyeball.
|
||||
|
||||
- **Uniform sentence length** (the single strongest current tell): a run of 3+ consecutive sentences within ±4 words of each other, paragraph after paragraph of 18–24-word sentences. Quick measurement on a file:
|
||||
|
||||
```bash
|
||||
tr '\n' ' ' < draft.txt | sed 's/[.!?] /\n/g' | awk '{print NF}'
|
||||
```
|
||||
|
||||
Human prose mixes 4-word sentences with 30-word sentences. Variance should be obvious at a glance.
|
||||
- **Uniform sentence shape**: every sentence opens subject-first; no fragments, no questions, no inversions.
|
||||
- **Uniform paragraph length**: every paragraph 3–4 sentences.
|
||||
- **Low specificity**: "many companies", "studies show", "experts agree", "recent research", "various factors" — generic where a human who knew the material would name names, numbers, dates. (Fix only with real specifics; never invented ones.)
|
||||
- **No friction**: nothing colloquial, no aside, no opinion held without a softener, nothing that risks being disagreed with.
|
||||
|
||||
## 7. Genre-specific instant tells
|
||||
|
||||
Covered in detail in [voices.md](voices.md); the headline items:
|
||||
|
||||
- **Reddit/forums**: bold mid-comment, bullet-pointed comments, "Hope this helps!", perfectly balanced takes.
|
||||
- **Tweets/X**: "🧵", "Let that sink in", line-broken one-clause-per-line cadence, ending on a question to drive engagement.
|
||||
- **LinkedIn**: one-sentence paragraphs stacked vertically, "Agree?", the not-X-but-Y move (its natural habitat).
|
||||
- **Academic**: "delve", "novel insights", puffed significance claims ("crucial implications for the field"), citation-free superlatives.
|
||||
- **Email**: "I hope this email finds you well", restating the recipient's question back at them, three-paragraph symmetry for a one-line answer.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Register Guide
|
||||
|
||||
What "good" means per genre, what tells are fatal there, and what the de-slopped text should sound like. Use in Phase 0 (fix the target) and Phase 4 (register check). Two universal rules first:
|
||||
|
||||
1. **Voice comes from commitment, not decoration.** A text sounds human when it asserts specific things a specific person believes, at the level of detail only someone who did the work would know. Slang, typos, and "personality" sprinkled on top do not produce this and read as humanizer-tool output.
|
||||
2. **Match the author, not a persona.** If the user supplied earlier writing or a draft with their own phrasing in it, keep their words wherever they survive the scan. De-slopping someone into a generic "casual" voice is just different slop.
|
||||
|
||||
## Academic article / paper
|
||||
|
||||
- **Goal**: precise claims, honest hedges, dense information. Formality stays; puffery goes.
|
||||
- **Fatal tells here**: "delve", "novel", inflated significance ("crucial implications", "paradigm shift"), rule-of-three in abstracts, negative parallelism in intros ("X is not merely a tool but a fundamental…"), em-dash chains.
|
||||
- **De-slop moves**: replace significance puffery with the actual finding and effect size. Hedges must be calibrated, not reflexive — "may" because the evidence is genuinely uncertain, not as seasoning. Keep passive voice where the venue expects it; do not inject first person or attitude. Numbers, conditions, and citations beat adjectives.
|
||||
- **Cadence**: long sentences are fine and normal; the tell is uniformity, not length. Vary clause structure.
|
||||
|
||||
## Tweet / X post
|
||||
|
||||
- **Goal**: one idea, said like a person, under the limit.
|
||||
- **Fatal tells here**: "🧵", "Let that sink in", "Read that again", one-clause-per-line stacking, ending on an engagement question, hashtag clusters, the not-X-but-Y move compressed into 200 characters.
|
||||
- **De-slop moves**: cut to the single claim. Lowercase is fine if that's the author's habit. No setup ("Hot take:") — just the take. A tweet that states an opinion without insurance reads human; a tweet that balances itself does not.
|
||||
|
||||
## Reddit post / comment
|
||||
|
||||
- **Goal**: reads like a knowledgeable person typing in a text box, because that's what reddit is.
|
||||
- **Fatal tells here** (reddit users are the most slop-sensitive audience on the internet): **any** bold in a comment, bullet-point essays, headers, "Hope this helps!", "Great question!", symmetric pro/con framing, em-dash density, perfect paragraphing.
|
||||
- **De-slop moves**: plain paragraphs, contractions, direct answers first. Mild hedges are human here ("iirc", "I might be wrong but") — but only the author's own. Concrete personal detail ("ran into this on a 2019 Outback") is the strongest human marker; never fabricate it, ask the author or drop it.
|
||||
|
||||
## LinkedIn post
|
||||
|
||||
- **Goal**: professional but specific. The platform's native style is so slop-adjacent that the bar is: would a colleague forward this without cringing?
|
||||
- **Fatal tells here**: stacked one-line paragraphs, "Agree?", "Let's connect", broetry rhythm, negative parallelism (this is its natural habitat — scan twice), rule-of-three value statements, "I'm humbled to announce".
|
||||
- **De-slop moves**: write actual paragraphs. Lead with the concrete event or number, not the lesson. One lesson max, stated once, not echoed in a closer.
|
||||
|
||||
## Email
|
||||
|
||||
- **Goal**: shortest text that's still warm enough for the relationship.
|
||||
- **Fatal tells here**: "I hope this email finds you well", restating the recipient's email back to them, three symmetric paragraphs wrapping a one-line answer, "Please don't hesitate to reach out".
|
||||
- **De-slop moves**: answer in the first sentence. Greeting and sign-off match the existing thread's register. Cut every sentence whose only job is politeness padding except one, if the relationship needs it.
|
||||
|
||||
## Blog post / newsletter / essay
|
||||
|
||||
- **Goal**: a person with a view, walking the reader through it.
|
||||
- **Fatal tells here**: "In today's fast-paced world" openers, intro-that-previews-three-sections skeleton, "In conclusion", bold-scattered prose, section headers every two paragraphs, engagement-bait closers.
|
||||
- **De-slop moves**: open inside the subject (a scene, a number, a claim). Let structure follow the argument instead of a template — real essays have asymmetric sections and loose ends. First person and digressions are allowed; they are how essays sound human. Keep headers only when the piece is long enough to need navigation.
|
||||
|
||||
## Marketing / landing copy
|
||||
|
||||
- **Goal**: concrete benefit, named audience, zero filler.
|
||||
- **Fatal tells here**: "seamless", "unlock", "empower", "game-changing", "effortless", rule-of-three feature triplets, false ranges ("from startups to enterprises"), every header a not-X-but-Y.
|
||||
- **De-slop moves**: replace each abstraction with the mechanism or the number ("Set up in 4 minutes" beats "seamless onboarding"). One verb per claim. Specificity is the whole game; if no specifics exist, that's a product-marketing problem the text can't fix — say so.
|
||||
|
||||
## Technical docs / README
|
||||
|
||||
- **Goal**: the reader gets unblocked fast.
|
||||
- **Fatal tells here**: "robust", "powerful", "blazingly fast" without benchmarks, "simply"/"just" before steps that aren't, marketing voice in reference material, emoji section headers.
|
||||
- **De-slop moves**: imperative mood, exact commands, exact versions, expected output. Adjectives almost to zero. Lists are fine here — docs are the one genre where "Term: definition" bullets are legitimate structure, so don't strip them; strip the puffery inside them.
|
||||
|
||||
## Academic-adjacent: cover letters, statements, grant prose
|
||||
|
||||
- **Goal**: claims about the author backed by evidence, in formal register.
|
||||
- **Fatal tells here**: "passionate", "deeply committed", "unique perspective", testament/tapestry vocabulary, rule-of-three trait lists, every paragraph ending with a not-X-but-Y synthesis.
|
||||
- **De-slop moves**: every trait claim becomes an event ("I led X, which produced Y"). Keep formality; cut self-puffery. The reader has read ten thousand of these — only specifics differentiate.
|
||||
@@ -0,0 +1,608 @@
|
||||
---
|
||||
name: interface-kit
|
||||
description: >
|
||||
Comprehensive implementation guide for exceptional, accessible, performant UI: design engineering craft,
|
||||
spatial layout, typography, spring physics, accessibility (WCAG AA), and component patterns.
|
||||
Use when building UI, components, landing pages, styling, or polishing frontend interfaces.
|
||||
concerns: [system-one-balance]
|
||||
---
|
||||
|
||||
# Interface Kit: Implementation Guide for Exceptional Interfaces
|
||||
|
||||
> If a DESIGN.md exists at the project root, its tokens and specifications override all defaults in this skill. This skill provides sensible defaults for when no design system exists, and implementation guidance that applies regardless.
|
||||
|
||||
> For deep dives on any section, see the reference files in this skill's `references/` directory.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Setup and Protocols
|
||||
|
||||
1. Inspect project root for `DESIGN.md`: If present, tokens and definitions override defaults here.
|
||||
2. Reference auxiliary guides in `references/`:
|
||||
- [`references/accessibility-checklist.md`](references/accessibility-checklist.md) (WCAG AA)
|
||||
- [`references/animation-playbook.md`](references/animation-playbook.md) (Spring physics & timing)
|
||||
- [`references/component-patterns.md`](references/component-patterns.md) (Accessible component recipes)
|
||||
- [`references/review-checklist.md`](references/review-checklist.md) (30-point pre-delivery audit)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Ingest and Check
|
||||
|
||||
1. Identify target platform (Web/React/Next.js/Tailwind/CSS).
|
||||
2. Pick a deliberate aesthetic direction from Section 3 before writing styles.
|
||||
3. Establish non-negotiable accessibility and contrast constraints.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Processing (Implementation Stack)
|
||||
|
||||
### 1. Core Philosophy
|
||||
|
||||
Taste is trained, not innate. Study why great interfaces feel right. Deconstruct apps you admire — the spacing, the timing, the weight of a shadow. The gap between "fine" and "exceptional" is built from hundreds of micro-decisions that users feel but never consciously notice.
|
||||
|
||||
**Unseen details compound.** A single rounded corner, a single eased transition, a single well-chosen shadow — none of these matter alone. Together they become "a thousand barely audible voices singing in tune." The cumulative effect is what separates craft from output.
|
||||
|
||||
**Beauty is leverage.** Polish is not vanity. Good defaults, considered typography, and intentional motion are real differentiators. Users trust interfaces that feel cared for. Investors notice. Competitors can't easily replicate taste.
|
||||
|
||||
**Intentionality over intensity.** Both bold maximalism and refined minimalism work — what fails is the absence of a clear point of view. Every visual decision should trace back to a deliberate conceptual direction. If you can't articulate WHY a choice was made, reconsider it.
|
||||
|
||||
**Choose a direction and execute with precision.** Don't hedge between styles. A brutalist page committed fully will always outperform a page that's "a little bit of everything." Commit, then refine.
|
||||
|
||||
**NEVER produce generic "AI slop" aesthetics.** No gratuitous gradients on white backgrounds. No cookie-cutter hero sections with stock illustrations. No safe, forgettable layouts that could belong to any product. Every interface should have a point of view that makes it recognizable.
|
||||
|
||||
---
|
||||
|
||||
### 2. The Priority Stack
|
||||
|
||||
When implementing UI, work through these priorities in order. Higher priorities are non-negotiable; lower priorities are polish that compounds quality.
|
||||
|
||||
| Priority | Level | What It Means |
|
||||
|----------|-------|---------------|
|
||||
| **Accessibility** | CRITICAL | Contrast 4.5:1, keyboard nav, ARIA semantics, visible focus rings. Ship nothing that excludes users. |
|
||||
| **Performance** | HIGH | WebP/AVIF images, lazy loading below fold, CLS < 0.1, transform-only animations on the compositor thread. |
|
||||
| **Typography** | HIGH | Font smoothing, text-wrap balance/pretty, tabular-nums for data, 65ch max line length. |
|
||||
| **Layout & Spatial** | HIGH | 4/8px grid, concentric border radius, optical alignment over geometric. |
|
||||
| **Color & Theme** | MEDIUM | HSL custom properties, semantic tokens, dark mode pairs tested separately. |
|
||||
| **Motion & Interaction** | MEDIUM | Frequency-based animation decisions, 150-300ms durations, ease-out default. |
|
||||
| **Polish & Details** | LOW | Layered shadows over borders, press feedback on buttons, staggered enter animations. |
|
||||
|
||||
Never skip a CRITICAL/HIGH item to chase a LOW item. A beautifully animated button that fails keyboard navigation is a net negative.
|
||||
|
||||
---
|
||||
|
||||
### 3. Aesthetic Direction
|
||||
|
||||
Before writing a single line of CSS, commit to a bold aesthetic direction. The most common failure mode in AI-generated UI is convergence on the same safe, forgettable look.
|
||||
|
||||
### Pick a Tone
|
||||
|
||||
Choose one and commit fully:
|
||||
|
||||
- **Brutally minimal** — generous whitespace, monospace type, stark contrast, near-zero decoration
|
||||
- **Maximalist chaos** — layered textures, clashing type scales, dense information, intentional visual noise
|
||||
- **Retro-futuristic** — CRT glow effects, monospace terminals, scan lines, neon on dark
|
||||
- **Organic / natural** — earth tones, rounded shapes, paper textures, hand-drawn accents
|
||||
- **Luxury / refined** — serif headlines, muted palettes, ample negative space, subtle gold or cream accents
|
||||
- **Editorial / magazine** — dramatic type hierarchy, full-bleed imagery, grid-breaking layouts
|
||||
- **Playful / bold** — bright primaries, chunky borders, exaggerated shadows, bouncy motion
|
||||
|
||||
### Match Complexity to Vision
|
||||
|
||||
Maximalist design demands elaborate code — layered backgrounds, complex grid structures, multiple font stacks. Minimalist design demands surgical precision — every pixel of spacing matters more when there's nothing to hide behind.
|
||||
|
||||
### The Ban List (When No DESIGN.md Exists)
|
||||
|
||||
When building without an existing design system, avoid these overused defaults that signal "AI-generated":
|
||||
|
||||
- **Fonts**: Inter, Roboto, Arial, system-ui as display fonts, Space Grotesk
|
||||
- **Colors**: Purple-to-blue gradients on white backgrounds
|
||||
- **Patterns**: Generic hero with centered text + CTA + stock illustration
|
||||
|
||||
Vary between light and dark themes, different font pairings, different aesthetic directions. Never converge on the same choices across projects.
|
||||
|
||||
### Visual Texture
|
||||
|
||||
Add depth through: gradient meshes, noise/grain overlays (`filter: url(#noise)`), layered transparencies, subtle background patterns, duotone image treatments.
|
||||
|
||||
**DESIGN.md overrides this entire section.** If DESIGN.md specifies Inter, use Inter. If it specifies purple gradients, use them. The ban list only applies when no design system exists and you're making aesthetic choices from scratch.
|
||||
|
||||
---
|
||||
|
||||
### 4. Typography Essentials
|
||||
|
||||
Typography is the single highest-leverage design element. Get it right and mediocre layouts still feel good. Get it wrong and nothing else saves it.
|
||||
|
||||
### Root Setup
|
||||
|
||||
```css
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
```
|
||||
|
||||
Apply font smoothing to the root layout. On macOS, the default sub-pixel rendering makes text appear heavier than the designer intended.
|
||||
|
||||
### Text Wrapping
|
||||
|
||||
```css
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p, li, dd, blockquote {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
```
|
||||
|
||||
`balance` distributes heading lines evenly. `pretty` avoids orphaned words in body text.
|
||||
|
||||
### Numeric Display
|
||||
|
||||
```css
|
||||
.data-value, .price, .counter, [data-numeric] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
```
|
||||
|
||||
Use `tabular-nums` for any number that updates dynamically — prices, counters, table columns. Without it, layout shifts as digit widths change.
|
||||
|
||||
### Scale and Rhythm
|
||||
|
||||
- **Base size**: 16px minimum for body text. Never go below 14px for any readable content.
|
||||
- **Line height**: 1.5-1.75 for body text, 1.1-1.3 for large headings.
|
||||
- **Max line length**: `max-width: 65ch` for body text. Long lines destroy readability.
|
||||
- **Type scale**: Pick a consistent scale and stick to it: 12 / 14 / 16 / 18 / 24 / 32 / 48 / 64.
|
||||
|
||||
### Font Pairing
|
||||
|
||||
Pair a distinctive display font with a refined body font. The display font carries personality; the body font carries readability. Use `font-weight` for hierarchy within a family:
|
||||
|
||||
- **Headings**: 600-700 (semibold to bold)
|
||||
- **Body**: 400 (regular)
|
||||
- **Labels / UI**: 500 (medium)
|
||||
|
||||
Always include font stack fallbacks:
|
||||
|
||||
```css
|
||||
--font-display: "Instrument Serif", "Georgia", serif;
|
||||
--font-body: "Söhne", "Helvetica Neue", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Color & Theme
|
||||
|
||||
### HSL Custom Properties (shadcn Pattern)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
Define semantic tokens: primary, secondary, destructive, muted, accent, background, foreground. Reference colors by semantic name — never hardcode hex values in components.
|
||||
|
||||
### Dark Mode
|
||||
|
||||
```css
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
/* ... desaturated, lighter tonal variants — NOT simply inverted */
|
||||
}
|
||||
```
|
||||
|
||||
Dark mode is not "invert colors." Use desaturated, lighter tonal variants. Backgrounds go dark but not pure black (`#000`). Text goes light but not pure white (`#fff`). Test contrast separately for dark mode — what passes in light may fail in dark.
|
||||
|
||||
### Contrast Requirements
|
||||
|
||||
- **WCAG AA minimum**: 4.5:1 for normal text, 3:1 for large text (18px+ bold or 24px+ regular)
|
||||
- Never convey information by color alone — always pair with an icon, label, or pattern
|
||||
- Test with browser devtools contrast checker or axe-core
|
||||
|
||||
### Color Confidence
|
||||
|
||||
Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Pick one or two hero colors and let the rest of the palette recede. A confident palette has clear hierarchy; an uncertain palette spreads color evenly and feels flat.
|
||||
|
||||
---
|
||||
|
||||
## 6. Spatial Design
|
||||
|
||||
### Concentric Border Radius
|
||||
|
||||
This is the single most common thing that makes nested UI elements feel "off":
|
||||
|
||||
```
|
||||
outer_radius = inner_radius + padding
|
||||
```
|
||||
|
||||
```css
|
||||
/* Correct: concentric */
|
||||
.card { border-radius: 16px; padding: 8px; }
|
||||
.card-inner { border-radius: 8px; } /* 16 - 8 = 8 */
|
||||
|
||||
/* Wrong: same radius on parent and child */
|
||||
.card { border-radius: 12px; }
|
||||
.card-inner { border-radius: 12px; } /* Looks bloated */
|
||||
```
|
||||
|
||||
When geometric centering looks off, align optically. Play/pause icons, dropdown carets, and asymmetric glyphs often need 1-2px manual nudges to look centered.
|
||||
|
||||
### Shadows Over Borders
|
||||
|
||||
Layer multiple transparent `box-shadow` values for natural depth instead of using borders:
|
||||
|
||||
```css
|
||||
.elevated {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.04),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 4px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
```
|
||||
|
||||
Multiple shadows at different spreads mimic how light works. A single hard shadow looks artificial.
|
||||
|
||||
### Image Outlines
|
||||
|
||||
Add a subtle inset outline to images and media for consistent depth against varied backgrounds:
|
||||
|
||||
```css
|
||||
img, video {
|
||||
outline: 1px solid rgba(0, 0, 0, 0.06);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
```
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
Use a 4px / 8px base incremental system. Every spacing value should be a multiple of 4:
|
||||
|
||||
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 / 96 / 128`
|
||||
|
||||
### Hit Areas
|
||||
|
||||
Minimum 44x44px for all interactive elements. If the visual element is smaller, extend the hit area with a pseudo-element:
|
||||
|
||||
```css
|
||||
.small-button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
}
|
||||
```
|
||||
|
||||
### Z-Index Scale
|
||||
|
||||
Define a layered scale and never use arbitrary values:
|
||||
|
||||
```css
|
||||
--z-base: 0;
|
||||
--z-dropdown: 10;
|
||||
--z-sticky: 20;
|
||||
--z-overlay: 40;
|
||||
--z-modal: 100;
|
||||
--z-toast: 1000;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Motion & Interaction
|
||||
|
||||
### The Frequency-Based Decision Framework
|
||||
|
||||
This is the most important mental model for animation decisions:
|
||||
|
||||
| Frequency | Examples | Animation |
|
||||
|-----------|----------|-----------|
|
||||
| **100+ times/day** | Keyboard shortcuts, command palette actions, tab switches | **None.** Zero animation. Instant. |
|
||||
| **Tens of times/day** | Hover effects, list item navigation, toggles | **Remove or drastically reduce.** 50-100ms max. |
|
||||
| **Occasional** | Modals, drawers, toasts, page transitions | **Standard animation.** 150-300ms. |
|
||||
| **Rare / first-time** | Onboarding, celebrations, empty states | **Can add delight.** 300-500ms, more elaborate. |
|
||||
|
||||
High-frequency animations feel sluggish. Low-frequency animations without motion feel jarring. Match the animation budget to usage frequency.
|
||||
|
||||
### Custom Easing Curves
|
||||
|
||||
Built-in CSS easings (`ease`, `ease-in-out`) are too weak. Define custom curves:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
```
|
||||
|
||||
### Duration Guide
|
||||
|
||||
| Element | Duration |
|
||||
|---------|----------|
|
||||
| Buttons, toggles | 100-160ms |
|
||||
| Tooltips | 125-200ms |
|
||||
| Dropdowns, popovers | 150-250ms |
|
||||
| Modals, drawers | 200-500ms |
|
||||
| Page transitions | 250-400ms |
|
||||
|
||||
UI animations should stay under 300ms. Never use `ease-in` for UI animations — it front-loads the pause and feels sluggish.
|
||||
|
||||
### Enter/Exit Asymmetry
|
||||
|
||||
Exits should be softer and faster than enters. An enter animation at 250ms should have its exit at 150-200ms.
|
||||
|
||||
### Split and Stagger Enter Animations
|
||||
|
||||
When multiple elements enter the viewport, stagger them by semantic chunks with ~50-100ms delay:
|
||||
|
||||
```css
|
||||
.stagger-item {
|
||||
animation: fadeSlideIn 300ms var(--ease-out) both;
|
||||
}
|
||||
.stagger-item:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-item:nth-child(2) { animation-delay: 60ms; }
|
||||
.stagger-item:nth-child(3) { animation-delay: 120ms; }
|
||||
```
|
||||
|
||||
### Scale Animations
|
||||
|
||||
Never animate from `scale(0)`. Start from `scale(0.9)` or higher, combined with opacity:
|
||||
|
||||
```css
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
```
|
||||
|
||||
### Press Feedback
|
||||
|
||||
Every pressable element should scale down slightly on `:active`:
|
||||
|
||||
```css
|
||||
button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
```
|
||||
|
||||
### Interruptibility
|
||||
|
||||
Use CSS transitions (not keyframe animations) for interactive state changes. Transitions can be interrupted mid-way; keyframes cannot. This matters for hover states, toggles, and any element the user might interact with rapidly.
|
||||
|
||||
### Popover Origin
|
||||
|
||||
Make popovers transform-origin aware — they should grow from their trigger element, not from center. Exception: modals always originate from center.
|
||||
|
||||
### Tooltip Hover Delay
|
||||
|
||||
Skip the tooltip delay on subsequent hovers. If the user has already waited for one tooltip, show the next one immediately.
|
||||
|
||||
### Reduced Motion
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Respect `prefers-reduced-motion`. Reduce animations — don't eliminate opacity and color transitions entirely, as those provide important feedback.
|
||||
|
||||
### Hover Gate
|
||||
|
||||
Gate hover animations behind a media query so touch devices don't trigger stuck hover states:
|
||||
|
||||
```css
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card:hover { transform: translateY(-2px); }
|
||||
}
|
||||
```
|
||||
|
||||
> Reference `references/animation-playbook.md` for deep dives on spring physics, gesture-driven animation, and complex choreography.
|
||||
|
||||
---
|
||||
|
||||
### 8. Component Craft
|
||||
|
||||
### Primitives
|
||||
|
||||
Use Radix UI primitives for accessible, unstyled foundations. Use CVA (class-variance-authority) for type-safe component variants:
|
||||
|
||||
```tsx
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
sm: "h-9 px-3 text-sm",
|
||||
default: "h-10 px-4 py-2",
|
||||
lg: "h-11 px-8 text-lg",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Button
|
||||
|
||||
- Scale on press (`transform: scale(0.97)` on `:active`)
|
||||
- Visible focus ring (never `outline: none` without replacement)
|
||||
- Loading state with spinner replacing label, maintaining button dimensions
|
||||
- Disabled state at `opacity: 0.5` with `pointer-events: none`
|
||||
|
||||
### Card
|
||||
|
||||
- Concentric border radius between card and inner elements
|
||||
- Layered shadows (not borders) for depth
|
||||
- Hover state: subtle elevation change (`translateY(-1px)` + shadow increase)
|
||||
|
||||
### Dialog / Modal
|
||||
|
||||
- Focus trap (keyboard cannot escape to elements behind)
|
||||
- ESC to close, click outside overlay to close
|
||||
- `transform-origin: center`, fade + scale enter animation
|
||||
- `aria-modal="true"`, `role="dialog"`, `aria-labelledby`
|
||||
|
||||
### Form
|
||||
|
||||
- Visible labels always — never placeholder-only inputs
|
||||
- Error messages near the field with `aria-live="polite"` for screen readers
|
||||
- Progressive disclosure: show advanced fields only when needed
|
||||
- Use React Hook Form + Zod for validation
|
||||
|
||||
### Theming
|
||||
|
||||
Use shadcn CSS variable pattern (HSL format) for all component colors. Wrap client-interactive components in server components for Next.js App Router compatibility.
|
||||
|
||||
> Reference `references/component-patterns.md` for the full component catalog with copy-paste implementations.
|
||||
|
||||
---
|
||||
|
||||
## 9. Accessibility Essentials
|
||||
|
||||
### Semantic HTML First
|
||||
|
||||
Use `<button>`, `<nav>`, `<main>`, `<header>`, `<footer>`, `<article>`, `<section>` before reaching for ARIA. A `<button>` gives you keyboard handling, focus management, and screen reader semantics for free. A `<div onClick>` gives you none of that.
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
- **Tab / Shift+Tab**: move between focusable elements
|
||||
- **Enter / Space**: activate buttons and links
|
||||
- **Arrow keys**: navigate within lists, menus, tabs, radio groups
|
||||
- **Escape**: close modals, popovers, dropdowns
|
||||
- **Home / End**: jump to first/last item in lists
|
||||
|
||||
### Focus Management
|
||||
|
||||
- Visible focus rings on all interactive elements — NEVER use `outline: none` without a replacement
|
||||
- Trap focus inside modals (Tab wraps within the modal, not behind it)
|
||||
- Restore focus to the trigger element when a modal/popover closes
|
||||
- Use `focus-visible` to show rings only for keyboard users, not mouse clicks:
|
||||
|
||||
```css
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
### ARIA Attributes
|
||||
|
||||
- `aria-label` for icon-only buttons: `<button aria-label="Close menu">X</button>`
|
||||
- `aria-labelledby` to associate headings with sections
|
||||
- `aria-describedby` to link help text or error messages to inputs
|
||||
- `aria-live="polite"` for dynamic content updates (toast messages, form errors)
|
||||
- `aria-hidden="true"` for decorative elements (icons next to text labels)
|
||||
- `aria-expanded` for toggleable elements (dropdowns, accordions)
|
||||
|
||||
### Color and Contrast
|
||||
|
||||
- WCAG AA: 4.5:1 for normal text, 3:1 for large text
|
||||
- Never use color as the sole indicator — pair with icons, text, or patterns
|
||||
- Test in both light and dark modes
|
||||
|
||||
### Images and Media
|
||||
|
||||
- Descriptive `alt` text for meaningful images: `alt="Dashboard showing 23% revenue growth"`
|
||||
- Empty `alt=""` for purely decorative images
|
||||
- Captions for video, transcripts for audio
|
||||
|
||||
### Navigation Aids
|
||||
|
||||
- **Skip link**: first focusable element, hidden until focused:
|
||||
|
||||
```html
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only">
|
||||
Skip to main content
|
||||
</a>
|
||||
```
|
||||
|
||||
- **Heading hierarchy**: sequential h1 through h6, no level skips. One `<h1>` per page.
|
||||
|
||||
### Touch Targets
|
||||
|
||||
- Minimum 44x44px interactive area
|
||||
- 8px minimum spacing between adjacent touch targets
|
||||
- Extend small visual elements with invisible padding or pseudo-elements
|
||||
|
||||
### Testing
|
||||
|
||||
- **Automated**: axe-core in CI, Lighthouse accessibility score 90+
|
||||
- **Manual**: full keyboard-only navigation test
|
||||
- **Screen reader**: test with VoiceOver (macOS) or NVDA (Windows)
|
||||
- **Visual**: zoom to 200%, check nothing breaks or overlaps
|
||||
|
||||
> Reference `references/accessibility-checklist.md` for the full audit guide with pass/fail criteria.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: The System One Balance Pass
|
||||
|
||||
- **Augment, Don't Handcuff**:
|
||||
- Do NOT stall the user with multiple rounds of aesthetic questionnaires when asked to build or style UI.
|
||||
- Commit to a cohesive aesthetic direction and deliver working, accessible code.
|
||||
- Always respect `prefers-reduced-motion` and provide visible focus rings.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Output Execution & Verification
|
||||
|
||||
Run through this checklist before considering any UI implementation complete:
|
||||
|
||||
### Typography
|
||||
- [ ] Font smoothing applied (`-webkit-font-smoothing: antialiased`)
|
||||
- [ ] Headings use `text-wrap: balance`
|
||||
- [ ] Dynamic numbers use `font-variant-numeric: tabular-nums`
|
||||
|
||||
### Color
|
||||
- [ ] All colors referenced via semantic tokens, no hardcoded hex in components
|
||||
- [ ] Color contrast meets WCAG AA (4.5:1 normal text, 3:1 large text)
|
||||
- [ ] Dark mode tested separately for contrast
|
||||
|
||||
### Spatial
|
||||
- [ ] Nested rounded elements use concentric border radius
|
||||
- [ ] Spacing follows 4px / 8px scale consistently
|
||||
- [ ] Interactive elements have 44x44px minimum hit area
|
||||
- [ ] Shadows used instead of borders where appropriate
|
||||
|
||||
### Motion
|
||||
- [ ] Animation frequency matches usage frequency (no animation on high-frequency actions)
|
||||
- [ ] No `transition: all` anywhere — specific properties only
|
||||
- [ ] Enter animations split and staggered where multiple elements appear
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
|
||||
### Accessibility
|
||||
- [ ] All interactive elements keyboard accessible
|
||||
- [ ] Focus rings visible on keyboard navigation (never `outline: none` without replacement)
|
||||
- [ ] Semantic HTML used before ARIA
|
||||
- [ ] `aria-live` on dynamic content updates
|
||||
|
||||
> Reference `references/review-checklist.md` for the extended 30-item checklist with severity ratings and automated testing commands.
|
||||
@@ -0,0 +1,425 @@
|
||||
# WCAG 2.1 AA Accessibility Audit Guide
|
||||
|
||||
Comprehensive checklist for building accessible web interfaces. Every requirement maps to WCAG 2.1 Level AA success criteria.
|
||||
|
||||
---
|
||||
|
||||
## 1. Semantic HTML Priority
|
||||
|
||||
ALWAYS use semantic HTML before reaching for ARIA. Native elements carry built-in keyboard behavior, focus management, and screen reader announcements that ARIA can only approximate.
|
||||
|
||||
### Element Selection Rules
|
||||
|
||||
| Instead of | Use |
|
||||
|---|---|
|
||||
| `<div role="button">` | `<button>` |
|
||||
| `<div role="navigation">` | `<nav>` |
|
||||
| `<div class="header">` | `<header>` |
|
||||
| `<div class="footer">` | `<footer>` |
|
||||
| `<span onClick>` | `<a href>` or `<button>` |
|
||||
| `<div role="list">` | `<ul>` / `<ol>` |
|
||||
| `<div class="table">` | `<table>` with `<thead>`, `<tbody>`, `<th>` |
|
||||
|
||||
### Landmark Elements
|
||||
|
||||
- `<main>` — one per page, wraps primary content
|
||||
- `<nav>` — navigation sections (label with `aria-label` when multiple exist)
|
||||
- `<header>` — introductory content or navigation aids
|
||||
- `<footer>` — footer content, copyright, related links
|
||||
- `<aside>` — tangentially related content (sidebars, callouts)
|
||||
- `<article>` — self-contained composition (blog post, comment, widget)
|
||||
- `<section>` — thematic grouping of content (always pair with a heading)
|
||||
|
||||
### Form Associations
|
||||
|
||||
- `<label>` with `for` attribute connected to the input's `id`
|
||||
- Group related inputs with `<fieldset>` and `<legend>`
|
||||
- Use `<optgroup>` for grouped select options
|
||||
|
||||
### Heading Hierarchy
|
||||
|
||||
- Sequential order: h1 -> h2 -> h3 -> h4 -> h5 -> h6
|
||||
- NEVER skip levels (e.g., h1 directly to h3)
|
||||
- One `<h1>` per page (the page title)
|
||||
- Headings must describe the content that follows
|
||||
|
||||
---
|
||||
|
||||
## 2. Keyboard Navigation Patterns
|
||||
|
||||
Every interactive element must be operable with a keyboard alone. No mouse-only interactions.
|
||||
|
||||
### Global Key Bindings
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Tab` | Move focus to next focusable element |
|
||||
| `Shift + Tab` | Move focus to previous focusable element |
|
||||
| `Enter` | Activate links, buttons, submit forms |
|
||||
| `Space` | Activate buttons, toggle checkboxes |
|
||||
| `Escape` | Close modals, dropdowns, popovers, tooltips |
|
||||
| `Arrow keys` | Navigate within composite widgets |
|
||||
| `Home` | Jump to first item in a list or range |
|
||||
| `End` | Jump to last item in a list or range |
|
||||
|
||||
### Composite Widget Navigation (Arrow Keys)
|
||||
|
||||
- **Tabs**: Left/Right arrows move between tabs
|
||||
- **Menus**: Up/Down arrows move between menu items
|
||||
- **Radio groups**: Arrow keys cycle through options, selecting as they go
|
||||
- **Listboxes**: Up/Down arrows move highlight, Space selects
|
||||
- **Tree views**: Up/Down navigate siblings, Right expands, Left collapses
|
||||
|
||||
### tabindex Rules
|
||||
|
||||
- `tabindex="0"` — places element in natural tab order (use for custom interactive elements)
|
||||
- `tabindex="-1"` — removes from tab order but allows programmatic focus via `element.focus()` (use for modal containers, skip-link targets, dynamically focused content)
|
||||
- **NEVER** use `tabindex > 0` — it overrides natural DOM order and creates an unpredictable, unmaintainable focus sequence
|
||||
|
||||
### Focus Order Principle
|
||||
|
||||
Focus order must match the visual reading order (left-to-right, top-to-bottom for LTR languages). If the DOM order does not match the visual layout, fix the DOM order rather than using positive tabindex values.
|
||||
|
||||
---
|
||||
|
||||
## 3. ARIA Attributes Reference
|
||||
|
||||
The first rule of ARIA: do not use ARIA if a native HTML element provides the behavior. When you must use ARIA, apply it correctly.
|
||||
|
||||
### Naming and Describing
|
||||
|
||||
| Attribute | Purpose | Example |
|
||||
|---|---|---|
|
||||
| `aria-label` | Names an element without visible text | Icon button: `<button aria-label="Close">X</button>` |
|
||||
| `aria-labelledby` | Points to another element as the label | Modal: `aria-labelledby="dialog-title"` |
|
||||
| `aria-describedby` | Provides additional description | Form hint: `aria-describedby="password-hint"` |
|
||||
|
||||
### Live Regions
|
||||
|
||||
| Attribute | Behavior |
|
||||
|---|---|
|
||||
| `aria-live="polite"` | Waits for current speech to finish before announcing (toasts, status updates) |
|
||||
| `aria-live="assertive"` | Interrupts current speech immediately (critical errors, urgent alerts) |
|
||||
| `aria-atomic="true"` | Re-reads entire region content on change, not just the delta |
|
||||
| `role="alert"` | Shorthand for `aria-live="assertive"` + `aria-atomic="true"` |
|
||||
| `role="status"` | Shorthand for `aria-live="polite"` + `aria-atomic="true"` |
|
||||
|
||||
### State and Properties
|
||||
|
||||
| Attribute | Purpose |
|
||||
|---|---|
|
||||
| `aria-expanded` | Indicates whether a collapsible section is open (`true`) or closed (`false`) |
|
||||
| `aria-haspopup` | Indicates the trigger opens a popup (`menu`, `listbox`, `dialog`, `grid`, `tree`) |
|
||||
| `aria-modal="true"` | Marks a dialog as modal (assistive tech should ignore content outside) |
|
||||
| `aria-hidden="true"` | Hides element from assistive technology (decorative images, duplicate content) |
|
||||
| `aria-invalid` | Marks a form field as having an error (`true`, `grammar`, `spelling`) |
|
||||
| `aria-required` | Indicates the field is required before form submission |
|
||||
| `aria-sort` | Indicates sort direction on table column headers (`ascending`, `descending`, `none`) |
|
||||
| `aria-selected` | Indicates selected state in single/multi-select widgets |
|
||||
| `aria-controls` | Identifies the element(s) controlled by this element |
|
||||
| `aria-current` | Indicates the current item in a set (`page`, `step`, `location`, `date`, `true`) |
|
||||
| `aria-disabled` | Marks element as disabled but still perceivable (unlike `disabled` attribute which removes from tab order) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Focus Management
|
||||
|
||||
### Visible Focus Indicators
|
||||
|
||||
- **NEVER** use `outline: none` or `outline: 0` without providing a custom alternative
|
||||
- Recommended default: `outline: 3px solid currentColor; outline-offset: 2px;`
|
||||
- Use `:focus-visible` for keyboard-only focus styling (hides ring on mouse click):
|
||||
|
||||
```css
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--focus-color, #2563eb);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
```
|
||||
|
||||
- Focus indicators must meet 3:1 contrast ratio against adjacent colors (WCAG 2.4.11)
|
||||
- Minimum focus indicator area: at least 2px perimeter around the component
|
||||
|
||||
### Modal Focus Trapping
|
||||
|
||||
When a modal opens:
|
||||
1. Move focus to the first focusable element inside the modal (or the modal container with `tabindex="-1"`)
|
||||
2. Trap Tab/Shift+Tab to cycle only through focusable elements within the modal
|
||||
3. Pressing Escape closes the modal
|
||||
4. On close, return focus to the element that triggered the modal
|
||||
|
||||
### Focus Restoration
|
||||
|
||||
- When a dropdown/popover/modal closes, return focus to its trigger element
|
||||
- When an item is deleted from a list, move focus to the nearest remaining item
|
||||
- When a dialog confirms an action, focus the result or next logical element
|
||||
|
||||
### SPA Route Changes
|
||||
|
||||
- On navigation, move focus to the main content heading or a skip-link target
|
||||
- Announce the new page title to screen readers using an `aria-live` region or document.title update
|
||||
- Use `<title>` updates: "Page Name | Site Name"
|
||||
|
||||
### Skip Links
|
||||
|
||||
- First focusable element on the page should be "Skip to main content"
|
||||
- Link target: `<main id="main-content" tabindex="-1">`
|
||||
- Visually hidden until focused:
|
||||
|
||||
```css
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
position: static;
|
||||
left: auto;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Color Contrast Requirements
|
||||
|
||||
### WCAG AA Minimum Ratios
|
||||
|
||||
| Element | Minimum Contrast Ratio |
|
||||
|---|---|
|
||||
| Normal text (< 24px, or < 18.66px if bold) | 4.5:1 |
|
||||
| Large text (>= 24px, or >= 18.66px if bold) | 3:1 |
|
||||
| UI components (borders, icons, form controls) | 3:1 |
|
||||
| Graphical objects (charts, infographics) | 3:1 |
|
||||
| Disabled elements | No requirement (but keep readable) |
|
||||
| Placeholder text | 4.5:1 (it is regular text) |
|
||||
|
||||
### Testing Tools
|
||||
|
||||
- Chrome DevTools: Elements panel -> Styles -> color swatch -> contrast ratio
|
||||
- axe-core browser extension
|
||||
- WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
|
||||
- Stark (Figma/Sketch plugin)
|
||||
|
||||
### Color Independence Rules
|
||||
|
||||
- **NEVER** convey information by color alone
|
||||
- Error states: red color + error icon + descriptive text message
|
||||
- Required fields: asterisk + "required" label text (not just red border)
|
||||
- Status indicators: color + icon + text label (e.g., green checkmark + "Complete")
|
||||
- Links in body text: color + underline (or other non-color differentiator)
|
||||
- Charts/graphs: use patterns, labels, or shapes in addition to color
|
||||
|
||||
### Dark Mode Considerations
|
||||
|
||||
- Test contrast ratios separately in dark mode
|
||||
- Use desaturated color variants, not simple CSS `invert()`
|
||||
- Background and foreground pairs must both be intentionally chosen
|
||||
- Semi-transparent overlays can reduce effective contrast -- verify computed values
|
||||
|
||||
---
|
||||
|
||||
## 6. Accessible Component Patterns
|
||||
|
||||
### Dropdown / Select
|
||||
|
||||
```
|
||||
trigger: aria-haspopup="listbox", aria-expanded="false|true"
|
||||
container: role="listbox"
|
||||
options: role="option", aria-selected="true|false"
|
||||
```
|
||||
|
||||
- Arrow keys navigate options
|
||||
- Typeahead: typing characters jumps to matching option
|
||||
- Enter/Space selects highlighted option
|
||||
- Escape closes without selecting
|
||||
- Selected option text updates trigger label
|
||||
|
||||
### Modal / Dialog
|
||||
|
||||
```
|
||||
container: role="dialog", aria-modal="true", aria-labelledby="title-id"
|
||||
title: id="title-id"
|
||||
close button: aria-label="Close dialog"
|
||||
```
|
||||
|
||||
- Focus moves into modal on open
|
||||
- Tab cycles within modal (focus trap)
|
||||
- Escape closes modal
|
||||
- Click on backdrop closes modal
|
||||
- Focus returns to trigger on close
|
||||
- Background content gets `aria-hidden="true"` or `inert`
|
||||
|
||||
### Tabs
|
||||
|
||||
```
|
||||
container: role="tablist"
|
||||
tab: role="tab", aria-selected="true|false", aria-controls="panel-id", tabindex="0|-1"
|
||||
panel: role="tabpanel", aria-labelledby="tab-id", tabindex="0"
|
||||
```
|
||||
|
||||
- Only the active tab has `tabindex="0"`; inactive tabs have `tabindex="-1"`
|
||||
- Left/Right arrows move between tabs (wrapping optional)
|
||||
- Home/End jump to first/last tab
|
||||
- Tab key moves focus from the active tab into the panel content
|
||||
|
||||
### Forms
|
||||
|
||||
- Every `<input>`, `<select>`, `<textarea>` has a visible `<label>`
|
||||
- Required fields: `aria-required="true"` + visual asterisk indicator
|
||||
- Error fields: `aria-invalid="true"` + `aria-describedby` pointing to error message element
|
||||
- Error messages: use `role="alert"` or `aria-live="assertive"` region
|
||||
- On failed submission: focus the first invalid field
|
||||
- Helper text: linked via `aria-describedby` to the associated input
|
||||
- Password fields: toggle visibility button with `aria-label` describing current state
|
||||
- Groups of related controls: `<fieldset>` + `<legend>`
|
||||
|
||||
### Accordion
|
||||
|
||||
```
|
||||
trigger: <button aria-expanded="true|false" aria-controls="panel-id">
|
||||
panel: id="panel-id", role="region", aria-labelledby="trigger-id"
|
||||
```
|
||||
|
||||
- Enter/Space toggles section
|
||||
- Only one section open at a time (optional, depends on design)
|
||||
- Panel content hidden with `hidden` attribute or `display: none` (not just visually)
|
||||
|
||||
### Toast / Notification
|
||||
|
||||
- Container: `role="status"` or `aria-live="polite"` (non-critical)
|
||||
- Critical notifications: `role="alert"` (assertive)
|
||||
- Must be dismissible (close button or auto-dismiss with sufficient time)
|
||||
- Auto-dismiss: minimum 5 seconds visible, pauses on hover/focus
|
||||
|
||||
---
|
||||
|
||||
## 7. prefers-reduced-motion
|
||||
|
||||
### Global Reset
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Nuanced Approach
|
||||
|
||||
Reduced motion means fewer/gentler animations, not zero motion:
|
||||
- **Keep**: opacity fades, color transitions that aid comprehension
|
||||
- **Remove**: parallax scrolling, zoom/scale transforms, slide/translate animations, auto-playing carousels
|
||||
- **Simplify**: complex multi-step animations to simple fades
|
||||
|
||||
### Framework Integration
|
||||
|
||||
React (framer-motion):
|
||||
```jsx
|
||||
import { useReducedMotion } from 'framer-motion';
|
||||
|
||||
function Component() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ x: shouldReduceMotion ? 0 : 100 }}
|
||||
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
CSS custom property approach:
|
||||
```css
|
||||
:root {
|
||||
--transition-speed: 0.3s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--transition-speed: 0.01ms;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing Approach
|
||||
|
||||
### Automated Testing
|
||||
|
||||
| Tool | Usage |
|
||||
|---|---|
|
||||
| axe-core | `npm install jest-axe` for unit tests; `expect(container).toHaveNoViolations()` |
|
||||
| Lighthouse | Accessibility score target: 90+ |
|
||||
| eslint-plugin-jsx-a11y | Static analysis for JSX accessibility issues |
|
||||
| pa11y | CLI/CI integration for automated page-level audits |
|
||||
| Playwright/axe | `@axe-core/playwright` for integration test accessibility checks |
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
1. **Keyboard-only navigation**: unplug mouse, navigate entire page with Tab, Enter, Arrows, Escape
|
||||
2. **Screen reader**: VoiceOver (macOS: Cmd+F5), NVDA (Windows, free), JAWS (Windows)
|
||||
3. **Zoom 200%**: content should reflow without horizontal scrolling or content clipping
|
||||
4. **Zoom 400%**: text should remain readable (WCAG 1.4.10 Reflow)
|
||||
5. **Focus indicators**: every interactive element shows a visible focus ring when focused via keyboard
|
||||
6. **Forced colors mode**: test in Windows High Contrast Mode (use `forced-colors` media query)
|
||||
7. **Text spacing**: override letter-spacing (0.12em), word-spacing (0.16em), line-height (1.5), paragraph-spacing (2em) -- content must remain readable
|
||||
|
||||
### CI Integration
|
||||
|
||||
```bash
|
||||
# Example: axe-core with Playwright in CI
|
||||
npx playwright test --project=accessibility
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
|---|---|
|
||||
| `outline: none` on focus | Use `:focus-visible` with a custom focus ring |
|
||||
| Placeholder as only label | Always use `<label>` element |
|
||||
| Icon button without label | Add `aria-label="Action description"` |
|
||||
| Color-only error indication | Add icon + descriptive text alongside color |
|
||||
| Missing alt text on images | Descriptive `alt` text, or `alt=""` for decorative images |
|
||||
| Heading level skip (h1 to h3) | Sequential hierarchy: h1 -> h2 -> h3 |
|
||||
| `tabindex > 0` | Use natural DOM order; only use `0` or `-1` |
|
||||
| Emoji used as functional icons | Use SVG icons with `aria-label` |
|
||||
| Auto-playing animation | Respect `prefers-reduced-motion` media query |
|
||||
| Non-dismissible modal | Always support Escape key to close |
|
||||
| `aria-hidden="true"` on focusable elements | Remove from tab order or remove `aria-hidden` |
|
||||
| Missing `lang` attribute on `<html>` | Set `<html lang="en">` (or appropriate language code) |
|
||||
| Autoplaying video/audio with sound | Require user interaction to start, or mute by default with controls |
|
||||
| Tiny tap targets on mobile | Minimum 44x44 CSS pixels for touch targets |
|
||||
| Using `title` attribute as primary label | `title` is unreliable; use `aria-label` or visible `<label>` |
|
||||
| Links that say "click here" or "read more" | Descriptive link text: "Read the accessibility guide" |
|
||||
| Missing form error summary | On submit failure, show summary of all errors at top of form |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Testing a New Component
|
||||
|
||||
Before marking any component as complete, verify:
|
||||
|
||||
1. Can you reach and operate it using only a keyboard?
|
||||
2. Does it have a visible focus indicator?
|
||||
3. Does it announce correctly in a screen reader?
|
||||
4. Does it meet color contrast ratios?
|
||||
5. Does it work at 200% zoom?
|
||||
6. Does it respect `prefers-reduced-motion`?
|
||||
7. Does it pass `jest-axe` / axe-core automated checks?
|
||||
8. Does it have appropriate semantic HTML or ARIA roles?
|
||||
9. Are all images, icons, and media labeled?
|
||||
10. Can it be operated with one hand on mobile (44x44px touch targets)?
|
||||
@@ -0,0 +1,545 @@
|
||||
# Animation Playbook
|
||||
|
||||
Deep-dive reference for animation patterns. The main SKILL.md references these techniques
|
||||
but does not include the full detail needed for implementation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Easing Curve Library
|
||||
|
||||
The built-in CSS keywords (`ease`, `ease-in`, `ease-out`, `ease-in-out`) produce weak,
|
||||
generic motion. Define custom curves as CSS custom properties so every animation in the
|
||||
project shares the same vocabulary.
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Strong ease-out — the default for UI interactions (enter, appear, respond) */
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
/* Strong ease-in-out — on-screen movement and morphing transitions */
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
|
||||
/* iOS-like drawer curve — slide-up sheets, bottom drawers */
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
|
||||
/* Snappy — fast micro-interactions, toggles, checkboxes */
|
||||
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
|
||||
|
||||
/* Emphasized deceleration — large surface transitions, page-level changes */
|
||||
--ease-decel: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
```
|
||||
|
||||
### When to use which
|
||||
|
||||
| Curve | Use case |
|
||||
| ---------------- | ---------------------------------------------- |
|
||||
| `--ease-out` | Elements entering the viewport, appearing |
|
||||
| `--ease-in-out` | Elements morphing shape, moving across screen |
|
||||
| `--ease-drawer` | Sheets, drawers, panels sliding into view |
|
||||
| `--ease-snappy` | Micro-interactions: toggles, checks, switches |
|
||||
| `--ease-decel` | Large page transitions, route changes |
|
||||
| `linear` | Constant-rate motion only: progress bars, spin |
|
||||
|
||||
Never use `ease-in` alone for UI elements — it makes things feel sluggish at the start.
|
||||
Reserve `linear` for continuous motion (loading spinners, progress indicators) where
|
||||
deceleration would look wrong.
|
||||
|
||||
**Resources**: [easing.dev](https://easing.dev), [easings.co](https://easings.co)
|
||||
for visual curve comparison and copying.
|
||||
|
||||
---
|
||||
|
||||
## 2. Spring Animations
|
||||
|
||||
Springs are physics-based. They do not have a fixed duration — they simulate mass,
|
||||
stiffness, and damping. This makes them ideal for anything interactive.
|
||||
|
||||
### When to use springs instead of easing curves
|
||||
|
||||
- Drag interactions (the element should follow the finger naturally)
|
||||
- Elements that feel "alive" (cards, floating actions, avatars)
|
||||
- Gestures that can be interrupted mid-animation
|
||||
- Mouse-tracking interactions (cursor followers, magnetic buttons)
|
||||
|
||||
### Apple-style spring (duration + bounce)
|
||||
|
||||
```js
|
||||
// Framer Motion / Motion One
|
||||
animate(element, { x: 100 }, {
|
||||
type: "spring",
|
||||
duration: 0.5,
|
||||
bounce: 0.2
|
||||
})
|
||||
```
|
||||
|
||||
This is the simpler API. `duration` controls overall timing, `bounce` controls overshoot.
|
||||
|
||||
### Traditional physics spring (mass + stiffness + damping)
|
||||
|
||||
```js
|
||||
animate(element, { x: 100 }, {
|
||||
type: "spring",
|
||||
mass: 1,
|
||||
stiffness: 100,
|
||||
damping: 10
|
||||
})
|
||||
```
|
||||
|
||||
More control, but harder to tune. Start with mass=1 and adjust stiffness/damping.
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Keep bounce subtle: **0.1 to 0.3** for most UI. Higher values feel toy-like.
|
||||
- Avoid bounce entirely for actions that need to feel decisive (confirms, deletes).
|
||||
- Springs **maintain velocity when interrupted** — if you change the target mid-animation,
|
||||
the element smoothly redirects. Keyframe animations restart from scratch.
|
||||
- Use `useSpring` (or equivalent) for mouse-tracking: it makes cursor followers feel
|
||||
natural instead of artificial. The lag is intentional and pleasant.
|
||||
- For lists, spring each item separately so they can settle independently.
|
||||
|
||||
---
|
||||
|
||||
## 3. clip-path Animation Patterns
|
||||
|
||||
`clip-path` is one of the most underused animation tools. It lets you reveal, hide,
|
||||
and transition content without layout shifts.
|
||||
|
||||
### Inset shape basics
|
||||
|
||||
```css
|
||||
/* Full visibility */
|
||||
clip-path: inset(0 0 0 0);
|
||||
|
||||
/* Clipped from bottom — only top portion visible */
|
||||
clip-path: inset(0 0 50% 0);
|
||||
|
||||
/* Fully hidden — clipped from all sides */
|
||||
clip-path: inset(50% 50% 50% 50%);
|
||||
|
||||
/* With border-radius */
|
||||
clip-path: inset(10px round 8px);
|
||||
```
|
||||
|
||||
The values are `inset(top right bottom left)` — how far each edge clips inward.
|
||||
|
||||
### Pattern: Tabs with perfect color transitions
|
||||
|
||||
Duplicate the entire tab list. Place one copy on top of the other. The bottom copy has
|
||||
inactive styles; the top copy has active styles. Animate `clip-path: inset(...)` on the
|
||||
top copy to reveal only the active tab region. The color transition is instantaneous and
|
||||
pixel-perfect — no fade needed.
|
||||
|
||||
```css
|
||||
.tabs-active-overlay {
|
||||
clip-path: inset(0 calc(100% - var(--tab-right)) 0 var(--tab-left));
|
||||
transition: clip-path 300ms var(--ease-out);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Hold-to-delete
|
||||
|
||||
Overlay a colored fill on the button. On `:active`, animate `clip-path` from
|
||||
`inset(0 100% 0 0)` to `inset(0 0 0 0)` over 2 seconds with `linear` timing (the user
|
||||
needs to see constant progress). On release, snap back with `200ms ease-out`.
|
||||
|
||||
```css
|
||||
.delete-btn::after {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
transition: clip-path 200ms var(--ease-out);
|
||||
}
|
||||
.delete-btn:active::after {
|
||||
clip-path: inset(0 0 0 0);
|
||||
transition: clip-path 2s linear;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Image reveals on scroll
|
||||
|
||||
Start with `clip-path: inset(0 0 100% 0)` (image hidden, clipped from bottom).
|
||||
Use IntersectionObserver to detect viewport entry, then animate to `inset(0 0 0 0)`.
|
||||
|
||||
```js
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.style.clipPath = 'inset(0 0 0 0)';
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
```
|
||||
|
||||
### Pattern: Comparison sliders
|
||||
|
||||
Overlay two images. Clip the top image by the drag position:
|
||||
`clip-path: inset(0 calc(100% - var(--pos)) 0 0)`. Update `--pos` on pointer move.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gesture Design
|
||||
|
||||
Gestures are the hardest animation category because they involve real-time user input
|
||||
and require physics-aware feedback.
|
||||
|
||||
### Momentum-based dismissal
|
||||
|
||||
Calculate velocity during drag:
|
||||
|
||||
```js
|
||||
const velocity = distance / elapsed; // px per ms
|
||||
if (velocity > 0.11) {
|
||||
dismiss(); // Fast enough — dismiss regardless of distance
|
||||
} else if (Math.abs(offset) > threshold) {
|
||||
dismiss(); // Far enough — dismiss regardless of speed
|
||||
} else {
|
||||
snapBack(); // Neither fast nor far — return to origin
|
||||
}
|
||||
```
|
||||
|
||||
The velocity threshold (0.11 px/ms) matters more than distance. A quick flick should
|
||||
dismiss even from a small offset.
|
||||
|
||||
### Damping at boundaries
|
||||
|
||||
When the user drags past a natural boundary (e.g., top of a scroll view), apply
|
||||
increasing resistance:
|
||||
|
||||
```js
|
||||
function dampedOffset(raw, boundary) {
|
||||
const overflow = raw - boundary;
|
||||
// Logarithmic damping — diminishing returns
|
||||
return boundary + Math.log(1 + Math.abs(overflow)) * 30 * Math.sign(overflow);
|
||||
}
|
||||
```
|
||||
|
||||
This produces the rubber-band effect. The element still moves, but progressively less.
|
||||
|
||||
### Pointer capture
|
||||
|
||||
Once a drag begins, call `element.setPointerCapture(event.pointerId)`. This ensures
|
||||
all subsequent pointer events route to this element even if the pointer leaves its
|
||||
bounds. Release on `pointerup`.
|
||||
|
||||
### Multi-touch protection
|
||||
|
||||
Track only the first pointer. If a second finger touches during a drag, ignore it:
|
||||
|
||||
```js
|
||||
let activePointerId = null;
|
||||
element.addEventListener('pointerdown', (e) => {
|
||||
if (activePointerId !== null) return; // Already tracking
|
||||
activePointerId = e.pointerId;
|
||||
element.setPointerCapture(e.pointerId);
|
||||
});
|
||||
```
|
||||
|
||||
### Friction instead of hard stops
|
||||
|
||||
Never hard-clamp position. Always allow movement with increasing resistance. Hard stops
|
||||
feel broken. Friction feels physical.
|
||||
|
||||
---
|
||||
|
||||
## 5. Stagger Patterns
|
||||
|
||||
Staggering creates a sense of flow by delaying each item slightly.
|
||||
|
||||
### CSS implementation
|
||||
|
||||
```css
|
||||
.stagger-item {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: stagger-in 400ms var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
@keyframes stagger-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stagger-item:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-item:nth-child(2) { animation-delay: 40ms; }
|
||||
.stagger-item:nth-child(3) { animation-delay: 80ms; }
|
||||
.stagger-item:nth-child(4) { animation-delay: 120ms; }
|
||||
.stagger-item:nth-child(5) { animation-delay: 160ms; }
|
||||
```
|
||||
|
||||
Or with a custom property:
|
||||
|
||||
```css
|
||||
.stagger-item {
|
||||
animation-delay: calc(var(--index) * 40ms);
|
||||
}
|
||||
```
|
||||
|
||||
Set `--index` via `style` attribute in markup or JS.
|
||||
|
||||
### Guidelines
|
||||
|
||||
- **30-80ms** per step is the sweet spot. Under 30ms looks simultaneous. Over 80ms
|
||||
feels sluggish.
|
||||
- Break content into **semantic chunks** — stagger cards, not individual lines of text.
|
||||
- **Never block interaction** during stagger animations. All items should be clickable
|
||||
immediately, even if not yet visible.
|
||||
- Cap the total stagger time. For a list of 20 items, stagger the first 5-6 and let the
|
||||
rest appear together.
|
||||
- Stagger on initial load only. Re-renders should not re-stagger.
|
||||
|
||||
---
|
||||
|
||||
## 6. Exit Animation Patterns
|
||||
|
||||
Exits are often neglected. They deserve as much care as entries.
|
||||
|
||||
### Principles
|
||||
|
||||
- **Exits should be faster than enters.** If enter is 400ms, exit should be 200-250ms.
|
||||
- **Use small fixed translateY** (8-12px) instead of full-height slides. Large movements
|
||||
during exit draw too much attention away from what remains.
|
||||
- **Opacity + scale combination** works better than opacity alone for removal. A slight
|
||||
`scale(0.96)` during fade-out makes it feel more physical.
|
||||
- **Asymmetric timing is intentional.** A hold-to-delete might take 2 seconds (deliberate),
|
||||
but the actual removal should be 200ms (snappy). The weight is in the decision, not
|
||||
the consequence.
|
||||
|
||||
### Exit with height collapse
|
||||
|
||||
When removing an item from a list, animate both the content (opacity + translate) and
|
||||
the container height. The content fades first, then the gap closes:
|
||||
|
||||
```css
|
||||
.item-exiting {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transition: opacity 150ms var(--ease-out),
|
||||
transform 150ms var(--ease-out);
|
||||
}
|
||||
.item-exiting-collapse {
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: height 200ms var(--ease-out) 100ms, /* delayed start */
|
||||
margin 200ms var(--ease-out) 100ms,
|
||||
padding 200ms var(--ease-out) 100ms;
|
||||
}
|
||||
```
|
||||
|
||||
### Tuning
|
||||
|
||||
There is no formula for the right opacity/height/transform combination. Adjust until it
|
||||
feels right. Test by performing the action 10 times quickly — if anything feels off on
|
||||
repetition, it needs work.
|
||||
|
||||
---
|
||||
|
||||
## 7. Performance Rules
|
||||
|
||||
Animation jank is unacceptable. These rules keep animations at 60fps.
|
||||
|
||||
### The compositing-only rule
|
||||
|
||||
Only animate properties that skip layout and paint:
|
||||
- `transform` (translate, scale, rotate)
|
||||
- `opacity`
|
||||
- `filter` (with caveats — see below)
|
||||
|
||||
Everything else triggers layout recalculation (width, height, margin, padding, top, left)
|
||||
or paint (background-color, box-shadow, border). Both are expensive.
|
||||
|
||||
### CSS vs JavaScript animations
|
||||
|
||||
- **CSS animations and transitions** run off the main thread on the compositor. Use them
|
||||
for predetermined animations (hover effects, enter/exit, state changes).
|
||||
- **Framer Motion `x`/`y` props are NOT hardware-accelerated.** They animate inline
|
||||
styles, which run on the main thread. Use the full transform string or CSS-based
|
||||
approaches for performance-critical animations.
|
||||
- **CSS variables on parent elements** cause expensive style recalculation when updated.
|
||||
If animating a CSS variable, update the `transform` property directly instead.
|
||||
|
||||
### Web Animations API (WAAPI)
|
||||
|
||||
For programmatic animations that need CSS-level performance:
|
||||
|
||||
```js
|
||||
element.animate(
|
||||
[
|
||||
{ transform: 'translateY(20px)', opacity: 0 },
|
||||
{ transform: 'translateY(0)', opacity: 1 }
|
||||
],
|
||||
{ duration: 400, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', fill: 'forwards' }
|
||||
);
|
||||
```
|
||||
|
||||
WAAPI runs on the compositor like CSS animations but is controlled from JavaScript.
|
||||
|
||||
### Blur and filter performance
|
||||
|
||||
- Keep `blur()` under **20px**, especially on Safari where large blurs are expensive.
|
||||
- `backdrop-filter: blur()` is even more expensive — use sparingly.
|
||||
- Prefer pre-blurred images over real-time blur when possible.
|
||||
|
||||
### will-change
|
||||
|
||||
- Only use `will-change` for `transform`, `opacity`, or `filter`.
|
||||
- **Never** use `will-change: all` — it promotes every property and wastes GPU memory.
|
||||
- Add `will-change` only when you observe first-frame stutter on an animation. It is a
|
||||
last resort, not a default.
|
||||
- Remove `will-change` after the animation completes if the element is long-lived.
|
||||
|
||||
### transition: all is banned
|
||||
|
||||
```css
|
||||
/* Bad — animates every property change, including ones you did not intend */
|
||||
transition: all 200ms ease;
|
||||
|
||||
/* Good — explicit about what animates */
|
||||
transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out);
|
||||
```
|
||||
|
||||
`transition: all` causes unexpected animations when other properties change and makes
|
||||
debugging difficult.
|
||||
|
||||
---
|
||||
|
||||
## 8. The Sonner Principles
|
||||
|
||||
Sonner (the toast library) demonstrates principles that apply broadly to dynamic UI
|
||||
components.
|
||||
|
||||
### Good defaults matter more than options
|
||||
|
||||
If you need 12 configuration props to make a component feel right, the defaults are
|
||||
wrong. The component should feel right out of the box.
|
||||
|
||||
### Use transitions, not keyframes, for dynamic UI
|
||||
|
||||
Toasts are added rapidly and unpredictably. Keyframe animations have fixed timelines
|
||||
that cannot adapt to rapid state changes. CSS transitions respond to the current state
|
||||
and interpolate naturally.
|
||||
|
||||
### Handle edge cases invisibly
|
||||
|
||||
- Pause toast timers when the browser tab is hidden (the user should not miss toasts).
|
||||
- When a toast is dismissed from the middle of a stack, the remaining toasts should
|
||||
fill the gap smoothly.
|
||||
- When multiple toasts arrive simultaneously, batch the visual update.
|
||||
|
||||
### Match motion personality to component personality
|
||||
|
||||
A success toast can be slightly bouncy. An error toast should be direct and firm.
|
||||
A loading toast should feel steady and patient. The animation communicates as much as
|
||||
the content.
|
||||
|
||||
---
|
||||
|
||||
## 9. @starting-style for Modern CSS Enter Animations
|
||||
|
||||
`@starting-style` defines the initial style of an element when it first renders.
|
||||
Combined with transitions, it creates enter animations in pure CSS — no JavaScript
|
||||
`useEffect` + `mounted` state needed.
|
||||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 400ms ease, transform 400ms ease;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When the `.toast` element is inserted into the DOM, the browser starts from the
|
||||
`@starting-style` values and transitions to the normal values.
|
||||
|
||||
### Works with display: none toggling
|
||||
|
||||
```css
|
||||
.dialog {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
transition: opacity 300ms var(--ease-out), display 300ms allow-discrete;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.dialog[hidden] {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
The `allow-discrete` keyword lets `display` participate in the transition timeline.
|
||||
|
||||
### Fallback for older browsers
|
||||
|
||||
When `@starting-style` is not supported, fall back to a `data-mounted` attribute pattern:
|
||||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 400ms ease, transform 400ms ease;
|
||||
}
|
||||
.toast:not([data-mounted]) {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
```
|
||||
|
||||
Add `data-mounted` via JavaScript after a single `requestAnimationFrame`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Debug Techniques
|
||||
|
||||
### Slow motion testing
|
||||
|
||||
Increase animation duration by 2-5x during development. At normal speed, problems are
|
||||
invisible. At 5x, you see every hitch, wrong easing, and misaligned property.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--debug-speed: 1; /* Change to 5 for slow-mo */
|
||||
}
|
||||
.animated {
|
||||
transition-duration: calc(200ms * var(--debug-speed));
|
||||
}
|
||||
```
|
||||
|
||||
### Chrome DevTools Animations panel
|
||||
|
||||
Open DevTools > More Tools > Animations. This panel shows:
|
||||
- A timeline of all running animations
|
||||
- Frame-by-frame scrubbing
|
||||
- Easing curve visualization
|
||||
- Duration and delay for each animation
|
||||
|
||||
Use the playback speed controls (25%, 10%) for detailed inspection.
|
||||
|
||||
### Real device testing
|
||||
|
||||
Touch interactions feel completely different on a real phone versus a trackpad simulator.
|
||||
Always test gestures, drag interactions, and spring animations on physical devices.
|
||||
|
||||
### Fresh eyes check
|
||||
|
||||
Review animations with fresh eyes the next day. What felt right at 11pm during
|
||||
development often feels too fast, too slow, or too dramatic the next morning.
|
||||
|
||||
### The checklist
|
||||
|
||||
Before shipping any animation, verify:
|
||||
- Smooth color transitions (no banding or flashing)?
|
||||
- Correct easing curve for the interaction type?
|
||||
- Right `transform-origin` (elements scaling/rotating from the expected point)?
|
||||
- All animated properties in sync (opacity and transform finishing together)?
|
||||
- No layout shift during the animation?
|
||||
- Works with `prefers-reduced-motion: reduce`?
|
||||
- Performs at 60fps on a mid-range device?
|
||||
@@ -0,0 +1,604 @@
|
||||
# Component Implementation Patterns
|
||||
|
||||
Deep-dive reference for building production interfaces with shadcn/ui, Radix UI, and modern React.
|
||||
|
||||
---
|
||||
|
||||
## 1. shadcn/ui Setup
|
||||
|
||||
```bash
|
||||
npx shadcn@latest init
|
||||
npx shadcn@latest add button input form card dialog select sheet toast
|
||||
```
|
||||
|
||||
Key concepts:
|
||||
|
||||
- **Not an npm package** -- components are copied into your project. You own the code and can modify it freely.
|
||||
- Built on **Radix UI** primitives, which provide accessibility out of the box (focus management, ARIA attributes, keyboard navigation).
|
||||
- Styled with **Tailwind CSS** utilities -- no CSS-in-JS runtime.
|
||||
- Required dependencies:
|
||||
- `class-variance-authority` (CVA) -- variant management
|
||||
- `clsx` -- conditional class joining
|
||||
- `tailwind-merge` -- deduplicates conflicting Tailwind classes
|
||||
- `lucide-react` -- icon library
|
||||
- `tailwindcss-animate` -- animation utilities
|
||||
|
||||
The `cn()` utility combines `clsx` and `tailwind-merge`:
|
||||
|
||||
```ts
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. CSS Variables for Theming (HSL Format)
|
||||
|
||||
shadcn uses HSL values without the `hsl()` wrapper so Tailwind can apply opacity modifiers:
|
||||
|
||||
```css
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
/* ... remaining dark overrides */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage in `tailwind.config.ts`:
|
||||
|
||||
```ts
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
// ...
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Button Patterns
|
||||
|
||||
Use CVA to define variants declaratively:
|
||||
|
||||
```tsx
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Design rules:
|
||||
|
||||
- **Press feedback**: `active:scale-[0.97]` gives tactile response without layout shift.
|
||||
- **Focus ring**: Always visible via `focus-visible:ring-2`. Never use `outline: none` without a replacement.
|
||||
- **Loading state**: Disable the button and show a spinner inline.
|
||||
|
||||
```tsx
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, isLoading, children, ...props }, ref) => (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={isLoading || props.disabled}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Form Patterns (React Hook Form + Zod)
|
||||
|
||||
Schema-first validation keeps validation logic co-located and type-safe:
|
||||
|
||||
```tsx
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
name: z.string().min(2).max(50),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
```
|
||||
|
||||
The shadcn Form components wire React Hook Form to accessible markup:
|
||||
|
||||
```tsx
|
||||
function SignUpForm() {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { email: "", password: "", name: "" },
|
||||
mode: "onBlur", // validate on blur, not keystroke
|
||||
})
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
// handle submission
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="you@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* ...more fields */}
|
||||
<Button type="submit" isLoading={form.formState.isSubmitting}>
|
||||
Sign Up
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Accessibility rules:
|
||||
|
||||
- `FormMessage` renders error text with `aria-describedby` linked to the input.
|
||||
- Inputs get `aria-invalid="true"` when in error state automatically.
|
||||
- Mark required fields with `aria-required="true"`.
|
||||
- Validate on **blur**, not on every keystroke -- reduces noise and respects user flow.
|
||||
- Use **progressive disclosure** for complex forms: show additional fields only when relevant.
|
||||
|
||||
---
|
||||
|
||||
## 5. Card Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription,
|
||||
CardContent, CardFooter,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
<Card className="hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Project Settings</CardTitle>
|
||||
<CardDescription>Manage your project configuration.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* form fields or content */}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button>Save</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
Design rules:
|
||||
|
||||
- **Concentric border radius**: Outer radius = inner radius + padding. If inner elements have `rounded-md` (6px) and padding is 16px, outer card should be `rounded-xl` (12px) or greater.
|
||||
- **Layered shadows**: Use multiple shadow values for natural depth -- `shadow-sm` at rest, `shadow-lg` on hover.
|
||||
- **Hover lift**: Subtle `translateY(-2px)` on hover, never more than 4px.
|
||||
- Use semantic color tokens (`bg-card`, `text-card-foreground`) so cards adapt to theme changes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dialog (Modal) Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Dialog, DialogTrigger, DialogContent,
|
||||
DialogHeader, DialogTitle, DialogDescription,
|
||||
DialogFooter, DialogClose,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Edit Profile</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make changes to your profile here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* form content */}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
Accessibility and interaction rules (handled by Radix):
|
||||
|
||||
- **Focus trap**: Focus stays inside the modal while open. Tab wraps from last to first focusable element.
|
||||
- **ESC to close**: Always. No exceptions.
|
||||
- **Click outside overlay**: Closes the dialog by default.
|
||||
- `aria-modal="true"` is set automatically.
|
||||
- `aria-labelledby` points to `DialogTitle`, `aria-describedby` points to `DialogDescription`.
|
||||
- **Restore focus**: When dialog closes, focus returns to the trigger element.
|
||||
- **Animation origin**: `transform-origin: center` -- dialogs are an exception to the popover origin-from-trigger rule since they appear center-screen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Select/Dropdown Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue,
|
||||
SelectContent, SelectItem, SelectGroup, SelectLabel,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
<Select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Fruits</SelectLabel>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="blueberry">Blueberry</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
Interaction rules:
|
||||
|
||||
- **Keyboard navigation**: Arrow keys to move between items, Enter/Space to select, ESC to close, type-ahead to jump to matching items.
|
||||
- ARIA: `aria-haspopup="listbox"` on trigger, `aria-expanded` toggles with open state.
|
||||
- **Transform origin**: Popover should animate from the trigger position (origin-aware), not from center.
|
||||
- **Tooltip delay skip**: If a user hovers over one select and then moves to another, skip the tooltip delay on the second hover.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sheet (Slide-over) Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Sheet, SheetTrigger, SheetContent,
|
||||
SheetHeader, SheetTitle, SheetDescription,
|
||||
SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">Open Menu</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right"> {/* "left" | "right" | "top" | "bottom" */}
|
||||
<SheetHeader>
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Browse sections of the app.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-2 py-4">
|
||||
{/* nav links */}
|
||||
</nav>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
```
|
||||
|
||||
Use cases:
|
||||
|
||||
- **Mobile navigation**: Slide from left with full-height overlay.
|
||||
- **Detail panels**: Slide from right to show item details without leaving the list view.
|
||||
- **Filters**: Slide from bottom on mobile for filter controls.
|
||||
|
||||
Sheets share the same accessibility behavior as Dialog: focus trap, ESC to close, overlay click to close, and focus restoration.
|
||||
|
||||
---
|
||||
|
||||
## 9. Toast/Notification Patterns
|
||||
|
||||
Using the shadcn Toast (or Sonner for a lighter API):
|
||||
|
||||
```tsx
|
||||
// With shadcn toast
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
function SaveButton() {
|
||||
const { toast } = useToast()
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
toast({
|
||||
title: "Changes saved",
|
||||
description: "Your settings have been updated.",
|
||||
})
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// With Sonner (simpler API)
|
||||
import { toast } from "sonner"
|
||||
|
||||
toast.success("Changes saved")
|
||||
toast.error("Something went wrong")
|
||||
toast.promise(saveSettings(), {
|
||||
loading: "Saving...",
|
||||
success: "Settings saved",
|
||||
error: "Could not save",
|
||||
})
|
||||
```
|
||||
|
||||
Design and accessibility rules:
|
||||
|
||||
- **Auto-dismiss**: 3-5 seconds for informational toasts. Errors should persist or have longer duration.
|
||||
- `aria-live="polite"` -- screen readers announce without stealing focus.
|
||||
- **CSS transitions, not keyframes** -- toasts can be triggered rapidly; transitions handle interruption gracefully while keyframes restart from the beginning.
|
||||
- **Pause timers** when the browser tab is hidden (`document.visibilityState`).
|
||||
- **Swipe to dismiss**: Support horizontal swipe with momentum detection (velocity > threshold = dismiss, otherwise snap back).
|
||||
|
||||
---
|
||||
|
||||
## 10. Table Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Table, TableHeader, TableBody, TableFooter,
|
||||
TableHead, TableRow, TableCell, TableCaption,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableCaption>A list of recent invoices.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoices.map((invoice) => (
|
||||
<TableRow key={invoice.id}>
|
||||
<TableCell className="font-medium">{invoice.id}</TableCell>
|
||||
<TableCell>{invoice.status}</TableCell>
|
||||
<TableCell className="text-right">{invoice.amount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Responsive**: Wrap table in `overflow-x-auto` container. Below tablet breakpoint, allow horizontal scroll rather than collapsing columns.
|
||||
- **Sortable columns**: Use `aria-sort="ascending"` or `aria-sort="descending"` on the active `TableHead`. Show a visual indicator (chevron icon).
|
||||
- **Virtualization**: For lists exceeding ~50 items, use `@tanstack/react-virtual` or similar to render only visible rows.
|
||||
- **Row distinction**: Use zebra striping (`even:bg-muted/50`) or subtle borders between rows. Never rely on color alone.
|
||||
|
||||
---
|
||||
|
||||
## 11. Chart Integration
|
||||
|
||||
When integrating charts (Recharts, Chart.js, or similar):
|
||||
|
||||
- **Match chart type to data intent**:
|
||||
- Trend over time: line chart
|
||||
- Comparison across categories: bar chart
|
||||
- Part-of-whole: pie/donut chart
|
||||
- Distribution: histogram
|
||||
- Correlation: scatter plot
|
||||
|
||||
- **Accessible color palettes**: Use colors distinguishable by colorblind users. Supplement with patterns, textures, or different shapes for data points.
|
||||
- **Always include a legend** and provide **tooltips on hover/focus** for precise values.
|
||||
- **Screen reader alternative**: Provide a visually hidden `<table>` with the same data so screen readers can access it.
|
||||
- **Respect `prefers-reduced-motion`**: Skip entrance animations or reduce them to simple fades when the user has requested reduced motion.
|
||||
|
||||
```tsx
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches
|
||||
|
||||
<LineChart data={data}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
animationDuration={prefersReducedMotion ? 0 : 500}
|
||||
/>
|
||||
</LineChart>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Server Component Wrapping (Next.js)
|
||||
|
||||
Most shadcn/ui components use React state or event handlers and require `"use client"`. Structure your components to keep data fetching in server components:
|
||||
|
||||
```tsx
|
||||
// app/dashboard/page.tsx (Server Component -- no "use client")
|
||||
import { getProjects } from "@/lib/data"
|
||||
import { ProjectList } from "./project-list"
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const projects = await getProjects()
|
||||
return <ProjectList projects={projects} />
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/dashboard/project-list.tsx (Client Component)
|
||||
"use client"
|
||||
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ProjectListProps {
|
||||
projects: { id: string; name: string; status: string }[]
|
||||
}
|
||||
|
||||
export function ProjectList({ projects }: ProjectListProps) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<Card key={project.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{project.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{project.status}</p>
|
||||
<Button variant="outline" size="sm">View</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The pattern: **Server component fetches data, passes to client component as serializable props.** This keeps the client bundle small and data fetching on the server.
|
||||
|
||||
---
|
||||
|
||||
## 13. CVA (class-variance-authority) Deep Dive
|
||||
|
||||
CVA lets you define component variants declaratively, replacing sprawling conditional class logic:
|
||||
|
||||
```ts
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
Key patterns:
|
||||
|
||||
- **Compose with `cn()`**: Always wrap CVA output with `cn()` so consumer-passed `className` can override defaults via `tailwind-merge`.
|
||||
- **Type extraction**: `VariantProps<typeof badgeVariants>` generates the TypeScript type for variant props automatically.
|
||||
- **Compound variants**: Handle combinations of variant values that need special styling:
|
||||
|
||||
```ts
|
||||
const inputVariants = cva("...", {
|
||||
variants: {
|
||||
size: { sm: "...", lg: "..." },
|
||||
state: { error: "...", success: "..." },
|
||||
},
|
||||
compoundVariants: [
|
||||
{ size: "sm", state: "error", class: "border-2 border-red-500" },
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
- **Use CVA for any component with visual variants** -- buttons, badges, alerts, inputs, cards. It replaces manual `if/else` class concatenation with a declarative, type-safe API.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Pre-Delivery Review Checklist
|
||||
|
||||
Extended 30-item checklist for UI implementation quality. Run through this before marking any UI task as complete.
|
||||
|
||||
## Typography (6 items)
|
||||
|
||||
### 1. Font Smoothing Applied
|
||||
- **Check**: Root layout has `-webkit-font-smoothing: antialiased`
|
||||
- **How**: Inspect `<html>` or `<body>` computed styles
|
||||
- **Failing looks like**: Text appears heavy/blurry on macOS, especially at small sizes
|
||||
|
||||
### 2. Headings Use text-wrap: balance
|
||||
- **Check**: All `<h1>`–`<h4>` elements have `text-wrap: balance`
|
||||
- **How**: Resize viewport to trigger wrapping — headings should break evenly
|
||||
- **Failing looks like**: One long line followed by a single orphan word
|
||||
|
||||
### 3. Body Text Uses text-wrap: pretty
|
||||
- **Check**: Paragraphs and body text use `text-wrap: pretty`
|
||||
- **How**: Check for orphaned words at the end of paragraphs
|
||||
- **Failing looks like**: A single short word sitting alone on the last line
|
||||
|
||||
### 4. Dynamic Numbers Use tabular-nums
|
||||
- **Check**: Counters, prices, timers, and data columns have `font-variant-numeric: tabular-nums`
|
||||
- **How**: Watch numbers update — layout should not shift
|
||||
- **Failing looks like**: Content jumps horizontally as digits change width
|
||||
|
||||
### 5. Line Length Controlled
|
||||
- **Check**: Body text containers are capped at `max-width: 65ch`
|
||||
- **How**: Measure character count on a full-width line
|
||||
- **Failing looks like**: Text stretching edge-to-edge on wide monitors, hard to read
|
||||
|
||||
### 6. Type Scale Consistency
|
||||
- **Check**: All text sizes come from the defined type scale (no arbitrary sizes)
|
||||
- **How**: Inspect font sizes — they should match scale values (12/14/16/18/24/32/48)
|
||||
- **Failing looks like**: Random sizes like 15px, 19px, 22px that aren't in the scale
|
||||
|
||||
## Color & Theme (5 items)
|
||||
|
||||
### 7. Semantic Color Tokens Only
|
||||
- **Check**: No hardcoded hex/rgb values in component code
|
||||
- **How**: Search for `#[0-9a-f]` or `rgb(` in component files
|
||||
- **Failing looks like**: `background: #3b82f6` instead of `bg-primary` or `var(--primary)`
|
||||
|
||||
### 8. WCAG AA Contrast Met
|
||||
- **Check**: Normal text ≥ 4.5:1, large text ≥ 3:1, UI components ≥ 3:1
|
||||
- **How**: Run axe-core or Chrome DevTools contrast checker
|
||||
- **Failing looks like**: Light gray text on white background, low-contrast placeholders
|
||||
|
||||
### 9. Dark Mode Contrast Verified
|
||||
- **Check**: Contrast ratios pass in dark mode separately
|
||||
- **How**: Toggle dark mode, re-run contrast checks
|
||||
- **Failing looks like**: Passing in light mode but failing in dark (common with desaturated variants)
|
||||
|
||||
### 10. Color Not Sole Information Channel
|
||||
- **Check**: Error, success, warning states use icon + text alongside color
|
||||
- **How**: View the page in grayscale (browser DevTools → Rendering → Emulate vision deficiency)
|
||||
- **Failing looks like**: Red border on error field with no icon or text explanation
|
||||
|
||||
### 11. Dark Mode Visual Review
|
||||
- **Check**: All surfaces, borders, shadows, and text are legible in dark mode
|
||||
- **How**: Toggle dark mode and visually scan every component
|
||||
- **Failing looks like**: Invisible borders, washed-out shadows, or text-on-background collision
|
||||
|
||||
## Layout & Spatial (5 items)
|
||||
|
||||
### 12. Concentric Border Radius
|
||||
- **Check**: Outer radius = inner radius + padding on all nested rounded elements
|
||||
- **How**: Inspect nested cards, buttons-in-containers, input groups
|
||||
- **Failing looks like**: Inner and outer corners don't follow the same curvature — looks "off"
|
||||
|
||||
| Before | After | Why |
|
||||
|--------|-------|-----|
|
||||
| Parent `rounded-lg` (12px), child `rounded-lg` (12px), padding 8px | Parent `rounded-xl` (16px), child `rounded-md` (8px), padding 8px | 8 + 8 = 16 — radii now concentric |
|
||||
|
||||
### 13. Spacing Follows Scale
|
||||
- **Check**: All padding, margin, and gap values are multiples of 4px
|
||||
- **How**: Inspect spacing values — no 5px, 7px, 13px, 19px etc.
|
||||
- **Failing looks like**: Inconsistent spacing that makes the layout feel uneven
|
||||
|
||||
### 14. Hit Areas Meet Minimum
|
||||
- **Check**: All interactive elements have at least 44×44px clickable area
|
||||
- **How**: Use browser DevTools to measure element + padding dimensions
|
||||
- **Failing looks like**: Tiny icon buttons, close buttons, or links that are hard to tap on mobile
|
||||
|
||||
### 15. Shadows Over Borders
|
||||
- **Check**: Depth is created with layered box-shadows, not solid borders between sections
|
||||
- **How**: Look for `border: 1px solid` between content sections
|
||||
- **Failing looks like**: Hard dividing lines instead of natural depth transitions
|
||||
|
||||
### 16. Optical Alignment Verified
|
||||
- **Check**: Icons in buttons, play triangles, and asymmetric elements are optically centered
|
||||
- **How**: Squint at the element — does it look centered to the eye?
|
||||
- **Failing looks like**: A play triangle that's geometrically centered but looks shifted left
|
||||
|
||||
## Motion & Interaction (7 items)
|
||||
|
||||
### 17. Animation Frequency Appropriate
|
||||
- **Check**: High-frequency actions (keyboard shortcuts, command palette) have NO animation
|
||||
- **How**: Review the frequency table — occasional actions get standard animation, frequent actions get none
|
||||
- **Failing looks like**: A command palette with a 300ms open animation that feels sluggish after the 50th use
|
||||
|
||||
### 18. No `transition: all`
|
||||
- **Check**: Every transition specifies exact properties
|
||||
- **How**: Search for `transition: all` or `transition-property: all`
|
||||
- **Failing looks like**: Unintended properties animating (color, padding, border) causing jank
|
||||
|
||||
### 19. Custom Easing Curves Used
|
||||
- **Check**: UI animations use custom bezier curves, not built-in `ease`, `ease-in`, `ease-out`
|
||||
- **How**: Inspect transition/animation easing values
|
||||
- **Failing looks like**: Animations feel generic and lack punch
|
||||
|
||||
### 20. Enter Animations Split and Staggered
|
||||
- **Check**: Multi-element entrances use 30-80ms stagger between items
|
||||
- **How**: Watch page load or section reveal — elements should cascade, not appear all at once
|
||||
- **Failing looks like**: An entire section popping in as one block
|
||||
|
||||
### 21. Press Feedback on Buttons
|
||||
- **Check**: All pressable elements have subtle `scale(0.96-0.97)` on `:active`
|
||||
- **How**: Click and hold buttons — they should compress slightly
|
||||
- **Failing looks like**: Clicking a button with zero visual feedback
|
||||
|
||||
### 22. prefers-reduced-motion Respected
|
||||
- **Check**: Animations reduce/simplify when the user has reduced motion enabled
|
||||
- **How**: Enable reduced motion in OS settings, reload, check all animations
|
||||
- **Failing looks like**: Full animations playing for users who opted out
|
||||
|
||||
### 23. Hover States Gated
|
||||
- **Check**: Hover animations are behind `@media (hover: hover) and (pointer: fine)`
|
||||
- **How**: Test on touch device or emulate touch in DevTools
|
||||
- **Failing looks like**: Hover states triggering on tap on mobile, causing sticky hover effects
|
||||
|
||||
## Accessibility (7 items)
|
||||
|
||||
### 24. Keyboard Navigation Complete
|
||||
- **Check**: Every interactive element is reachable and operable with keyboard only
|
||||
- **How**: Unplug mouse, Tab through entire page, operate every control
|
||||
- **Failing looks like**: Unreachable buttons, inoperable dropdowns, trapped focus
|
||||
|
||||
### 25. Focus Rings Visible
|
||||
- **Check**: Every focusable element has a visible focus indicator
|
||||
- **How**: Tab through the page and verify each element shows focus
|
||||
- **Failing looks like**: `outline: none` with no replacement, invisible focus state
|
||||
|
||||
### 26. Semantic HTML Used
|
||||
- **Check**: `<button>` for actions, `<a>` for links, `<nav>` for navigation, proper heading hierarchy
|
||||
- **How**: Inspect the DOM — look for `<div onclick>` or `<span>` where buttons should be
|
||||
- **Failing looks like**: Divs with click handlers instead of buttons, missing landmarks
|
||||
|
||||
### 27. ARIA Labels on Icon Buttons
|
||||
- **Check**: Every icon-only button has `aria-label` describing its action
|
||||
- **How**: Inspect icon buttons in DevTools or run axe-core
|
||||
- **Failing looks like**: Screen reader announcing "button" with no context
|
||||
|
||||
### 28. Form Errors Accessible
|
||||
- **Check**: Error messages use `aria-live` or `role="alert"`, linked via `aria-describedby`
|
||||
- **How**: Submit an invalid form, check screen reader announces errors
|
||||
- **Failing looks like**: Visual error message that screen reader users never hear
|
||||
|
||||
### 29. Images Have Alt Text
|
||||
- **Check**: Meaningful images have descriptive `alt`, decorative images have `alt=""`
|
||||
- **How**: Search for `<img>` without `alt` attribute
|
||||
- **Failing looks like**: Screen reader announcing file names or nothing for important images
|
||||
|
||||
### 30. Skip Link Present
|
||||
- **Check**: First focusable element is "Skip to main content" link
|
||||
- **How**: Tab once on page load — skip link should appear
|
||||
- **Failing looks like**: Keyboard users forced to Tab through entire header/nav on every page
|
||||
|
||||
## Quick Pass/Fail Summary
|
||||
|
||||
Use this table to record results:
|
||||
|
||||
| # | Item | Pass | Notes |
|
||||
|---|------|------|-------|
|
||||
| 1 | Font smoothing | | |
|
||||
| 2 | text-wrap: balance | | |
|
||||
| 3 | text-wrap: pretty | | |
|
||||
| 4 | tabular-nums | | |
|
||||
| 5 | Line length | | |
|
||||
| 6 | Type scale | | |
|
||||
| 7 | Semantic tokens | | |
|
||||
| 8 | WCAG contrast | | |
|
||||
| 9 | Dark mode contrast | | |
|
||||
| 10 | Color not sole channel | | |
|
||||
| 11 | Dark mode visual | | |
|
||||
| 12 | Concentric radius | | |
|
||||
| 13 | Spacing scale | | |
|
||||
| 14 | Hit areas | | |
|
||||
| 15 | Shadows over borders | | |
|
||||
| 16 | Optical alignment | | |
|
||||
| 17 | Animation frequency | | |
|
||||
| 18 | No transition: all | | |
|
||||
| 19 | Custom easing | | |
|
||||
| 20 | Staggered enter | | |
|
||||
| 21 | Press feedback | | |
|
||||
| 22 | Reduced motion | | |
|
||||
| 23 | Hover gated | | |
|
||||
| 24 | Keyboard nav | | |
|
||||
| 25 | Focus rings | | |
|
||||
| 26 | Semantic HTML | | |
|
||||
| 27 | ARIA labels | | |
|
||||
| 28 | Form errors | | |
|
||||
| 29 | Alt text | | |
|
||||
| 30 | Skip link | | |
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: junior-to-senior
|
||||
description: >
|
||||
Adversarial senior-engineer review for agent-generated plans, designs, and architectures.
|
||||
Diagnoses altitude failures (fog vs tunnel) using live codebase research and current SOTA best practices, then rewrites into a scoped senior plan.
|
||||
Use when asked to review a plan, "senior review", "review this like a staff engineer", or before executing high-blast-radius plans.
|
||||
concerns: [system-one-balance]
|
||||
---
|
||||
|
||||
# Junior to Senior
|
||||
|
||||
Assume the plan in front of you was written by a capable junior: fluent, confident, and trained on the past. Build a senior reviewer grounded in **this codebase as it actually exists** and **the state of the art today**, then tear down the plan and rebuild it.
|
||||
|
||||
This skill exists because agent-generated plans fail at two altitudes:
|
||||
- **Fog**: The plan describes the high level fine ("add caching", "handle auth", "make it scalable") but never commits on the hard parts. No interfaces, no data shapes, no failure handling, no named libraries.
|
||||
- **Tunnel**: The plan dives into function signatures and file diffs without product vision, scoping boundaries, or justification for why this approach beats the boring alternative.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Setup and Protocols
|
||||
|
||||
1. Review governing reference playbooks:
|
||||
- [references/review-rubric.md](references/review-rubric.md) for altitude diagnostics, vague-phrase blacklists, and severity definitions.
|
||||
- [references/research-playbook.md](references/research-playbook.md) for codebase inspection queries, SOTA verification, and source ranking.
|
||||
2. Ground all critique in concrete evidence:
|
||||
- Codebase claims cite specific `file:line`.
|
||||
- SOTA claims cite primary documentation, release notes, or RFCs with dates.
|
||||
- If web research is unavailable, mark findings `[training-data, unverified]`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Ingest and Check
|
||||
|
||||
1. **Capture & Freeze the Artifact**:
|
||||
- Identify the target plan: a plan just produced in conversation, a pasted RFC/design doc, or a repository document.
|
||||
- Restate or quote the artifact in full before reviewing so the review targets a fixed specification.
|
||||
- If no plan exists, generate the junior draft or ask the user for their initial requirements first.
|
||||
2. **Altitude Diagnostic**:
|
||||
- Classify the artifact: **fog**, **tunnel**, or **mixed**.
|
||||
- Fog test: Can a competent engineer begin implementation tomorrow without making unstated product/architecture decisions?
|
||||
- Tunnel test: Does the plan state who this is for, what success means, what is out of scope, and why this beats the obvious alternative?
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Processing (Core Logic)
|
||||
|
||||
### Step 1: Construct the Senior Profile
|
||||
- **Domain Extraction**: Identify the 2–5 load-bearing technical domains the plan touches.
|
||||
- **Codebase Research**: Inspect repository lockfiles/manifests, actual dependency versions, existing conventions, data models, and constraints.
|
||||
- **SOTA Web Research**: Query current documentation and changelogs for deprecations, supersessions, or known postmortem failure modes.
|
||||
|
||||
### Step 2: Adversarial Critique
|
||||
- Challenge every vague term ("simple", "scalable", "robust", "handle gracefully").
|
||||
- Specify concrete data shapes, boundary contracts, migration paths, and rollback strategies.
|
||||
- Assign findings to three strict severity tiers:
|
||||
- **Blocker**: The plan fails as written or violates project invariants.
|
||||
- **Major**: Functions, but is significantly worse than current best practice or repo conventions.
|
||||
- **Minor**: Non-blocking improvements and ergonomic polish.
|
||||
- Acknowledge what the junior plan got right (credit preserved in rewrite).
|
||||
|
||||
### Step 3: Promote the Plan (v2)
|
||||
Rewrite the plan into senior form:
|
||||
1. **Goal & Non-Goals**: Crisp product intent and explicit out-of-scope boundaries.
|
||||
2. **Decisions & Rationale**: Each load-bearing choice with chosen option, version, rejected alternatives, and cited evidence.
|
||||
3. **Design at Right Altitude**: Exact contracts, schemas, and error boundaries for hard parts; coarse strokes for routine boilerplate.
|
||||
4. **Sequencing**: Measurable milestones with observable verification gates.
|
||||
5. **Risks & Rollback**: Blast-radius assessment and escape hatches.
|
||||
6. **Open Questions for Human**: Genuine product decisions requiring user judgment (the senior scopes; it does not fabricate product intent).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: The System One Balance Pass
|
||||
|
||||
- **Augment, Don't Handcuff**:
|
||||
- Do NOT second-guess routine developer requests with unnecessary reviews.
|
||||
- If the user asks for immediate, direct execution ("implement this now", "fix bug"), proceed directly without forcing an unsolicited junior-to-senior review pass.
|
||||
- Never fabricate synthetic blockers just to appear rigorous; if a plan is solid, state "this plan holds" and proceed.
|
||||
- **Calibrated Advisories**:
|
||||
- Offer the promoted plan as an upgrade option; do not silently overwrite the user's intent.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Output Execution & Verification
|
||||
|
||||
Deliver the review and promoted plan formatted as two structured sections:
|
||||
|
||||
```markdown
|
||||
## Senior Review
|
||||
|
||||
**Altitude diagnosis:** fog | tunnel | mixed — [one-sentence justification]
|
||||
|
||||
### Blockers
|
||||
- [B1] Finding — evidence (file:line or source+date) — concrete fix.
|
||||
|
||||
### Major
|
||||
- [M1] Finding — evidence — concrete fix.
|
||||
|
||||
### Minor
|
||||
- [m1] Finding — polish recommendation.
|
||||
|
||||
### What the junior got right
|
||||
- [Preserved choices and good foundations]
|
||||
|
||||
---
|
||||
|
||||
## Promoted Plan (v2)
|
||||
[Goal, Decisions, Scoped Architecture, Milestones, Rollback, Open Questions]
|
||||
|
||||
## Delta Summary
|
||||
- 3–6 concise bullets explaining key upgrades from v1 to v2.
|
||||
```
|
||||
@@ -0,0 +1,59 @@
|
||||
# Senior Research Playbook
|
||||
|
||||
Reference for Phase 1 of the `junior-to-senior` skill: how the senior earns expertise the junior doesn't have. Two tracks, run in this order — code research grounds the review in this repo; web research grounds it in the present.
|
||||
|
||||
## Track 1: Code research
|
||||
|
||||
Goal: know what is *true here* before judging the plan against it. Prefer a context-isolated explore subagent for broad sweeps; bring back conclusions, not file dumps.
|
||||
|
||||
Checklist per plan:
|
||||
|
||||
1. **Versions.** Read the lockfile/manifest (`package-lock.json`, `poetry.lock`, `go.mod`, `Cargo.lock`, ...) for every dependency the plan touches. Record exact pinned versions. Any plan step that assumes an API must be checked against the pinned version's docs, not the latest.
|
||||
2. **Conventions.** Find 2-3 existing implementations of the closest analogous feature. How does this repo do routing, errors, config, tests, migrations? The plan either matches or justifies the break.
|
||||
3. **Prior decisions.** Search for ADRs, `docs/`, design notes, and revealing commit messages on the touched paths. A plan that re-litigates a settled decision without knowing it was settled is a blocker.
|
||||
4. **Real constraints.** Deploy target, build system, CI time budget, supported platforms, existing data volume and shape. These kill more plans than design taste does.
|
||||
5. **Blast radius.** What actually imports/calls the things the plan changes? The junior plan's scope estimate is a guess; the dependency graph is a fact.
|
||||
|
||||
## Track 2: Web research
|
||||
|
||||
Goal: find where the world moved after the training cutoff. Research the plan's *load-bearing decisions* (from Phase 1a), not every line — typically 3-7 searches total, not 30.
|
||||
|
||||
### Query patterns
|
||||
|
||||
For each load-bearing decision, run the subset that applies:
|
||||
|
||||
- `<library> changelog` / `<library> release notes <current year>` — catch deprecations and new APIs since cutoff.
|
||||
- `<approach> vs <alternative> <current year>` — check whether the tradeoff has flipped.
|
||||
- `<pattern> deprecated` / `<pattern> considered harmful` — find published reversals.
|
||||
- `<technology> best practices <current year>` — only useful when followed to a primary source; the query itself attracts SEO spam, so treat listicle results as pointers, not evidence.
|
||||
- `<library> security advisory` / check the project's GitHub security tab — non-negotiable for auth, crypto, parsing, and anything touching user input.
|
||||
- `site:github.com <library> issues <feature>` — maintainer-stated direction and known footguns.
|
||||
|
||||
Always pin the current year or "latest" into queries — undated queries return the same era the model was trained on, which defeats the point.
|
||||
|
||||
### Source quality ranking
|
||||
|
||||
1. Official documentation, changelogs, release notes, RFCs — cite freely.
|
||||
2. Maintainer blog posts, GitHub issues/discussions where maintainers state direction.
|
||||
3. Engineering postmortems and benchmarks from teams that ran the thing in production (with dates and numbers).
|
||||
4. Conference talks, well-known practitioner blogs — corroborate before citing for a blocker.
|
||||
5. SEO listicles, AI-generated tutorials, undated content — never evidence, at most a pointer to a real source.
|
||||
|
||||
Every cited source gets a date check. A "best practices" article from before the relevant major version is training-data-era knowledge wearing a URL.
|
||||
|
||||
### What you are looking for
|
||||
|
||||
- **Deprecations** — the plan's approach is discouraged or removed in current versions.
|
||||
- **Supersessions** — a newer pattern/API has clearly won (look for the old way's own docs pointing at the new way — that's the strongest signal).
|
||||
- **Hard-won lessons** — advisories, postmortems, benchmarks that change the tradeoff the junior made on priors.
|
||||
- **Confirmations** — the junior's choice still holds. Record these too; they go in "What the junior got right".
|
||||
|
||||
### When to stop
|
||||
|
||||
- Each load-bearing decision has either one primary source confirming/refuting it, or two independent secondary sources agreeing.
|
||||
- Two consecutive searches on a decision return nothing newer than what you knew — the world likely didn't move; mark it confirmed-by-absence and move on.
|
||||
- Diminishing returns: research budget belongs on blockers and majors, not minors. If a finding would be minor either way, don't research it.
|
||||
|
||||
### No web access
|
||||
|
||||
Run Track 1 fully, skip Track 2, and tag every best-practice claim in the review `[training-data, unverified]`. Tell the user the review is grounded in the codebase but not refreshed against current SOTA, and name the decisions most likely to have shifted so they can spot-check.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Senior Review Rubric
|
||||
|
||||
Reference for Phase 2 (altitude diagnosis) and Phase 3 (adversarial review) of the `junior-to-senior` skill.
|
||||
|
||||
## Altitude diagnostics
|
||||
|
||||
Classify each section of the artifact independently. A plan is usually **mixed**: fogged on the hard parts, tunneled on the easy ones — because the junior wrote detail where it was comfortable and abstraction where it was not. That inversion (detail on easy parts, fog on hard parts) is itself a finding.
|
||||
|
||||
### Fog tests (too vague)
|
||||
|
||||
Run these against every component, step, or workstream the plan names. Each "no" is a finding.
|
||||
|
||||
1. **Start-tomorrow test** — could a competent engineer begin this item tomorrow without making an architecture or product decision themselves? If they'd have to choose a library, design a schema, or define an API first, the plan didn't plan it.
|
||||
2. **Interface test** — does anything that crosses a boundary (function, service, queue, file, network) have its shape written down? Names of fields, not "the relevant data".
|
||||
3. **Failure test** — for each external interaction (network, disk, user input, third-party API), does the plan say what happens when it fails? "Handle errors" is not an answer.
|
||||
4. **Quantity test** — are load-bearing quantities stated? Expected row counts, payload sizes, request rates, latency budgets. "Should be fast" is fog.
|
||||
5. **Named-technology test** — is every "a cache / a queue / some auth layer" either named (with version) or explicitly listed as an open decision with the candidates?
|
||||
|
||||
### Tunnel tests (too granular / missing vision)
|
||||
|
||||
Run these against the artifact as a whole. Each "no" is a finding.
|
||||
|
||||
1. **Audience test** — does the plan say who this is for and what they can do afterward that they couldn't before?
|
||||
2. **Success test** — is there an observable definition of success? A metric, a demo, a passing test suite, a user behavior — something checkable.
|
||||
3. **Non-goals test** — is anything explicitly out of scope? A plan with no non-goals has not been scoped, only described.
|
||||
4. **Alternative test** — does the plan say why this approach beat the obvious boring alternative? If no alternative was considered, the choice was a default, not a decision.
|
||||
5. **Sequencing test** — is there an ordering with a smallest useful version first, or is it a flat list of equally-weighted tasks?
|
||||
6. **Proportionality test** — does the detail land where the risk is? Twenty lines on a helper function and one line on the data migration means the plan is upside down.
|
||||
|
||||
## Vague-word blacklist
|
||||
|
||||
When these appear without immediate quantification or specification, challenge them with the concrete question they are hiding from:
|
||||
|
||||
| Word | Hidden question |
|
||||
|---|---|
|
||||
| simple / straightforward | Simple compared to what? What did you not have to handle? |
|
||||
| scalable | To what number, on what axis, measured how? |
|
||||
| robust / resilient | Against which specific failures? What is the recovery path? |
|
||||
| handle gracefully | What exactly happens? Retry, drop, queue, surface to user? |
|
||||
| performant / fast | What latency/throughput budget, at what percentile? |
|
||||
| secure | Against which threat model? Who is the attacker? |
|
||||
| flexible / extensible | For which anticipated change? Flexibility has a cost — who pays it? |
|
||||
| later / eventually / for now | Is this a sequencing decision or an unowned risk? Who reopens it, triggered by what? |
|
||||
| etc. / and so on | The list was the work. Finish it. |
|
||||
| appropriate / as needed | By whose judgment, applied when? |
|
||||
| leverage / utilize | Usually decorating an undecided choice. Name the thing. |
|
||||
|
||||
## Severity definitions
|
||||
|
||||
- **Blocker** — the plan fails as written. Examples: targets an API the pinned dependency version doesn't have; contradicts an existing architectural decision in the repo without acknowledging it; omits a data migration that the change requires; relies on a pattern that has a published security advisory against it; an entire hard component is fog (fails the start-tomorrow test).
|
||||
- **Major** — the plan works but is meaningfully worse than the current state of the art or misfit to this repo. Examples: hand-rolls something a maintained, already-installed dependency provides; uses a pattern superseded since the training cutoff (with source); detail is inverted (proportionality failure); no rollback story for a hard-to-reverse step; success criteria exist but aren't observable.
|
||||
- **Minor** — polish. Naming, doc gaps, small idiom mismatches with the surrounding codebase, ordering tweaks that reduce risk but don't change the outcome.
|
||||
|
||||
Calibration rules:
|
||||
|
||||
- Severity reflects consequence, not effort-to-fix. A one-line version bump can be a blocker.
|
||||
- Every finding carries evidence (file:line, or source + date) and a concrete fix. A finding without a fix is a question — put it in "Open questions" instead.
|
||||
- Do not inflate. Three real blockers reads as a serious review; ten padded ones reads as noise and gets ignored.
|
||||
- Track "what the junior got right" with the same care as faults. The rewrite must preserve it, and the user needs to see the review is calibrated, not performatively hostile.
|
||||
|
||||
## Adversarial discipline
|
||||
|
||||
- **Steelman first.** Before attacking a choice, state the strongest case for it in one or two sentences. If you cannot, you do not understand it well enough to reject it.
|
||||
- **Attack the artifact, not the author.** Findings name the text's failure, not the agent's.
|
||||
- **Concede when beaten.** If research validates the junior's choice, say so and move on. An adversarial review that cannot return "this holds" is a ritual, not a review.
|
||||
- **One altitude per finding.** Do not bundle "this is vague" with "this library is outdated" — they have different fixes.
|
||||
- **No invented requirements.** If the review wants a constraint the user never stated (e.g., "must support 1M users"), that is an open question for the human, not a finding.
|
||||
Reference in new issue
Block a user