Add local agent workspace files and skills
Includes .agents/, additional .claude/skills/, and skills-lock.json.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# caveman
|
||||
|
||||
Talk like smart caveman. Same brain, fewer tokens.
|
||||
|
||||
## What it does
|
||||
|
||||
Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts ~65-75% of output tokens with full accuracy preserved. Mode persists for the whole session until changed or stopped.
|
||||
|
||||
Six intensity levels:
|
||||
|
||||
| Level | What change |
|
||||
|-------|-------------|
|
||||
| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. |
|
||||
| `full` | Default. Drop articles, fragments OK, short synonyms. |
|
||||
| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. |
|
||||
| `wenyan-lite` | Classical Chinese register, light compression. |
|
||||
| `wenyan-full` | Maximum 文言文. 80-90% character reduction. |
|
||||
| `wenyan-ultra` | Extreme classical compression. |
|
||||
|
||||
Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part.
|
||||
|
||||
## How to invoke
|
||||
|
||||
```
|
||||
/caveman # full mode (default)
|
||||
/caveman lite # lighter compression
|
||||
/caveman ultra # extreme compression
|
||||
/caveman wenyan # classical Chinese
|
||||
stop caveman # back to normal prose
|
||||
```
|
||||
|
||||
## Example output
|
||||
|
||||
Question: "Why does my React component re-render?"
|
||||
|
||||
Normal prose:
|
||||
> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue.
|
||||
|
||||
Caveman (full):
|
||||
> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`.
|
||||
|
||||
Caveman (ultra):
|
||||
> Inline obj prop → new ref → re-render. `useMemo`.
|
||||
|
||||
## See also
|
||||
|
||||
- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions
|
||||
- [Caveman README](../../README.md) — repo overview, install, benchmarks
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||
---
|
||||
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
## Persistence
|
||||
|
||||
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
|
||||
|
||||
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||
|
||||
## Rules
|
||||
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations reader can't decode. Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
|
||||
Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
|
||||
|
||||
No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is.
|
||||
|
||||
Pattern: `[thing] [action] [reason]. [next step].`
|
||||
|
||||
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||
|
||||
## Intensity
|
||||
|
||||
| Level | What change |
|
||||
|-------|------------|
|
||||
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
|
||||
| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl) — prose words only, never real code symbols/function names. Strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate |
|
||||
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||
|
||||
Example — "Why React component re-render?"
|
||||
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||
- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。"
|
||||
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
|
||||
|
||||
Example — "Explain database connection pooling."
|
||||
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
|
||||
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
|
||||
|
||||
## Auto-Clarity
|
||||
|
||||
Drop caveman when:
|
||||
- Security warnings
|
||||
- Irreversible action confirmations
|
||||
- Multi-step sequences where fragment order or omitted conjunctions risk misread
|
||||
- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions)
|
||||
- User asks to clarify or repeats question
|
||||
|
||||
Resume caveman after clear part done.
|
||||
|
||||
Example — destructive op:
|
||||
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||
> ```sql
|
||||
> DROP TABLE users;
|
||||
> ```
|
||||
> Caveman resume. Verify backup exist first.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: fuck-slop
|
||||
description: >
|
||||
De-slop pass for any text: detects and erases the statistical fingerprints of
|
||||
AI writing (negative parallelism / "not X but Y", em-dash abuse, rule-of-three,
|
||||
false ranges, puffery vocabulary, uniform cadence, hedged both-sidesing) and
|
||||
rewrites the text into its target register — academic article, tweet, reddit
|
||||
post, email, blog, anything between. Use when the user says "fuck slop",
|
||||
"f*ck slop", "deslop", "de-slop this", "remove the AI tells", "humanize this",
|
||||
"make this not sound like AI", or invokes /fuck-slop. Also use before
|
||||
publishing any agent-drafted prose.
|
||||
---
|
||||
|
||||
# F*ck Slop
|
||||
|
||||
Strip every mark of AI writing from a text and make it good in its genre. Not "make it pass a detector" — make it read like a specific person with a specific point wrote it for a specific audience.
|
||||
|
||||
## Why this is a loop, not a style guide
|
||||
|
||||
The worst tells — above all the **"not X but Y"** family — are not vocabulary mistakes. They are emergent properties of how LLMs generate text: preference tuning rewards balanced, contrastive, comprehensive-sounding framing, so the contrast move is baked into the model's priors. Two consequences drive this skill's architecture:
|
||||
|
||||
1. **You cannot reliably see your own slop.** The same priors that produce the pattern make it invisible on re-read. Detection must be mechanical — regex against a fixed catalog — never "does this look AI to me?"
|
||||
2. **Rewriting reintroduces slop.** Ask a model to remove "it's not just X, it's Y" and it produces "this is less about X than Y" — the same move in a wig. So every rewrite gets re-scanned, and the loop runs until the scan is clean.
|
||||
|
||||
Workflow: **Scan → Diagnose → Rewrite by meaning → Re-scan → (repeat) → Register check.**
|
||||
|
||||
## Phase 0: Fix the target
|
||||
|
||||
Before touching the text, establish:
|
||||
|
||||
- **Genre and venue** — academic article, tweet, reddit post, LinkedIn, email, blog, docs, marketing. If not stated and not obvious from the text, ask. Genre decides which tells are fatal and what "good" means; see [references/voices.md](references/voices.md).
|
||||
- **Audience and stance** — who reads it, and what the author actually claims. Slop is what fills the space where a claim should be; you cannot remove it without knowing the claim.
|
||||
- **Constraints** — length limits, required citations, house style.
|
||||
|
||||
## Phase 1: Mechanical scan
|
||||
|
||||
Run the detection patterns from [references/tells.md](references/tells.md) against the text. If the text is in a file (or you can write it to a temp file), run the grep commands in that reference literally — the catalog is written as runnable `grep -Ein` patterns. Otherwise apply each pattern by hand, line by line.
|
||||
|
||||
Produce a finding list: line/sentence, matched pattern, tell category. Also run the two structural checks that regex can't fully catch:
|
||||
|
||||
- **Cadence**: flag any run of 3+ consecutive sentences within ±4 words of the same length, and any paragraph where every sentence has the same shape (subject–verb–elaboration).
|
||||
- **Formatting**: bold scattered through prose, emoji-decorated headers or bullets, "**Term:** definition" bullet lists, headers on a text too short to need them, a tidy intro–three-points–conclusion skeleton.
|
||||
|
||||
Report the findings to the user as a short table before rewriting (category, count, worst example). This is the diagnosis; the user should see what was wrong.
|
||||
|
||||
## Phase 2: Rewrite by meaning, not by frame
|
||||
|
||||
Go finding by finding. The cardinal rule: **never fix a pattern by paraphrasing the pattern.** Fix it by deciding what the sentence actually asserts, then asserting that.
|
||||
|
||||
### The "not X but Y" family — three-way triage
|
||||
|
||||
Every negative parallelism gets exactly one of these treatments:
|
||||
|
||||
1. **The negation is a strawman** (nobody believes X). Delete the X half entirely and assert Y directly, with whatever evidence the text has.
|
||||
- *"It's not just a tool, it's a fundamental shift in how teams work"* → *"Teams that adopted it stopped holding standups within a month."*
|
||||
2. **The contrast is real** (people genuinely hold X). Then earn it: name who holds X, say concretely why Y beats it. A real contrast survives being made specific; slop doesn't.
|
||||
3. **The sentence asserts nothing** (the contrast is decoration on an empty claim). Delete the whole sentence. Most cases are this one.
|
||||
|
||||
Banned escape hatches — these are the same move and count as new findings: "less about X than Y", "X matters, but Y matters more", "the real X is Y", "the question isn't X, it's Y", "X? Y." (rhetorical-question variant), and the em-dash variant "— not X, but Y".
|
||||
|
||||
### Everything else
|
||||
|
||||
- **Puffery and inflated vocabulary** (pivotal, seismic, testament, tapestry, landscape, delve…): replace with the plain word, or with the concrete fact the puffery was hiding. "Plays a vital role in" → "does".
|
||||
- **Rule-of-three lists**: keep the strongest item, cut the rest — unless all three carry distinct information, in which case keep them and break the rhythm (different lengths, different syntax).
|
||||
- **False ranges** ("from X to Y"): if you can't name a meaningful midpoint between X and Y, it's not a range — name the two things or cut one.
|
||||
- **Hedged both-sidesing** ("it's worth noting", auto-counterpoints, "while X, it's also true that Y"): commit. One opinion, stated, owned. A counterpoint stays only if the author genuinely concedes it.
|
||||
- **Uniform cadence**: vary deliberately. Follow a long sentence with a short one. Fragments are legal. Don't apply a formula (alternating long/short is its own tell) — read the paragraph aloud and break wherever the rhythm is metronomic.
|
||||
- **Low specificity**: replace "many companies" / "studies show" / "recent research" with the actual names, numbers, and dates — **only from the source text, the conversation, or verifiable research you actually do**. Never invent specifics. If the author needs to supply one, leave a marked placeholder: `[ADD: which study?]`.
|
||||
- **Stock skeleton**: kill throat-clearing openers ("In today's fast-paced world…"), summary conclusions ("In conclusion… Ultimately…"), and engagement-bait endings ("What do you think?"). Start where the point starts; stop when it's made.
|
||||
|
||||
### What not to do — overcorrection is also slop
|
||||
|
||||
- No fake typos, forced slang, or manufactured "voice". Humanizer-tool output is its own genre of slop.
|
||||
- Em dashes are not banned. Humans use them. The tell is density and the double-dash "— not X, but —" move. Budget: at most one em dash per ~150 words, never two in a sentence.
|
||||
- Don't trade precision for personality in academic or technical text. There, de-slopping means cutting puffery and committing to claims — not adding attitude.
|
||||
- Preserve the author's meaning, claims, and facts exactly. This is a style pass, not a content edit. Flag, don't silently fix, anything that looks factually wrong.
|
||||
|
||||
## Phase 3: Verify loop
|
||||
|
||||
Re-run the full Phase 1 scan **on your rewritten text**. This step is not optional and not a formality — expect your own rewrite to contain new tells, because the model writing it has the same priors that created them. Fix and re-scan until a pass produces zero pattern hits and the cadence check passes. Cap at 4 passes; if a pattern survives 4 passes, rewrite that sentence from scratch starting from its bare claim ("what fact or opinion is this sentence for?").
|
||||
|
||||
## Phase 4: Register check
|
||||
|
||||
Check the clean text against its genre profile in [references/voices.md](references/voices.md): right length, right formality, right person, genre-specific tells gone (e.g. on reddit: no bold, no bullet essay; in academic prose: no first-person hot takes added). Then the final test — read it aloud. Anywhere you wouldn't say it to the actual audience, rewrite that sentence.
|
||||
|
||||
Deliver: the rewritten text, plus a brief change log (categories fixed, counts, and number of verify passes it took).
|
||||
@@ -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,209 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Calibrated grilling session for stress-testing a plan, design, idea, or decision. First assesses the user's topic knowledge, confidence, and desired pressure level, then asks one question at a time with recommended answers. Use when user says "grill me", "stress-test this", "challenge my plan", "interview me", or wants a plan probed without being overwhelmed.
|
||||
---
|
||||
|
||||
# Grill Me
|
||||
|
||||
Interview the user until the plan is clear, defensible, and ready for action.
|
||||
|
||||
This is not hostile debate. It is calibrated pressure. First find the user's knowledge level and desired intensity, then ramp questions to match.
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Ask one question at a time.
|
||||
- Give a recommended answer for every question.
|
||||
- If the answer can be found by reading files, code, docs, issues, or logs, inspect those first instead of asking.
|
||||
- Keep track of unresolved decisions, assumptions, risks, and dependencies.
|
||||
- Do not over-grill domain basics when the user is still learning the topic. Teach the missing frame briefly, then ask the next useful question.
|
||||
- Do not under-grill confident experts. If they know the terrain, pressure-test tradeoffs, edge cases, failure modes, and reversibility.
|
||||
- Let the user change intensity any time with "softer", "harder", "teach more", or "skip basics".
|
||||
|
||||
## Phase 1: Frame The Target
|
||||
|
||||
Identify what should be grilled before asking about comfort. If the topic is not clear, ask:
|
||||
|
||||
> What plan, design, or decision should I grill?
|
||||
>
|
||||
> Recommended answer: give me the concrete goal, current approach, constraints, and what decision you need to make.
|
||||
|
||||
If context already contains the plan, summarize it in 3-6 bullets and ask for correction:
|
||||
|
||||
> I think target is: [...]
|
||||
>
|
||||
> Recommended answer: "Yes, grill that" or "Adjust: ..."
|
||||
|
||||
## Phase 2: Calibration
|
||||
|
||||
Before grilling the topic, ask a short calibration question unless the user's level is already obvious from context.
|
||||
|
||||
Ask:
|
||||
|
||||
> Before I grill the plan: what is your current comfort with this topic, and how hard do you want the pressure?
|
||||
>
|
||||
> Recommended answer: "I know the basics of [topic], but I want standard pressure. Explain missing concepts briefly, then keep pushing."
|
||||
|
||||
Use the user's answer to set two dials:
|
||||
|
||||
### Knowledge Level
|
||||
|
||||
- **New** - user lacks core vocabulary or model of the domain.
|
||||
- **Working** - user understands basics and can discuss tradeoffs.
|
||||
- **Expert** - user knows domain deeply and wants sharper critique.
|
||||
|
||||
### Pressure Level
|
||||
|
||||
- **Light** - clarify goals, constraints, and missing context.
|
||||
- **Standard** - challenge assumptions, tradeoffs, and execution path.
|
||||
- **Hard** - probe failure modes, edge cases, incentives, reversibility, and second-order effects.
|
||||
|
||||
If the user does not answer calibration, default to:
|
||||
|
||||
- Knowledge: **Working**
|
||||
- Pressure: **Standard**
|
||||
|
||||
## Phase 3: Build The Decision Map
|
||||
|
||||
Create a private decision map while asking questions one at a time:
|
||||
|
||||
- Goal - what success means.
|
||||
- User or customer - who this affects.
|
||||
- Constraints - time, money, stack, team, policy, risk.
|
||||
- Options - obvious alternatives and why current option wins.
|
||||
- Dependencies - what must be true first.
|
||||
- Risks - what breaks, gets expensive, or becomes irreversible.
|
||||
- Validation - how user will know it worked.
|
||||
- Rollback - how to undo or recover.
|
||||
|
||||
Do not dump the full map unless user asks. Use it to choose the next question.
|
||||
|
||||
## Phase 4: Question Ladder
|
||||
|
||||
Move through this ladder. Stop early if the plan becomes clear enough or user asks to stop.
|
||||
|
||||
### 1. Goal Fit
|
||||
|
||||
Questions:
|
||||
|
||||
- What outcome matters most?
|
||||
- What would make this not worth doing?
|
||||
- What problem are we solving, and for whom?
|
||||
|
||||
### 2. Constraint Reality
|
||||
|
||||
Questions:
|
||||
|
||||
- What hard constraint cannot move?
|
||||
- What resource bottleneck decides the plan?
|
||||
- What assumption would kill the plan if false?
|
||||
|
||||
### 3. Option Pressure
|
||||
|
||||
Questions:
|
||||
|
||||
- What are the top two alternatives?
|
||||
- Why this approach over the boring one?
|
||||
- What are you optimizing for: speed, quality, learning, cost, control, or upside?
|
||||
|
||||
### 4. Execution Path
|
||||
|
||||
Questions:
|
||||
|
||||
- What is the smallest useful version?
|
||||
- What has to happen first?
|
||||
- What can be deferred without harming the goal?
|
||||
|
||||
### 5. Failure Modes
|
||||
|
||||
Questions:
|
||||
|
||||
- How does this fail in production or real use?
|
||||
- What edge case would embarrass the plan?
|
||||
- What part is hardest to observe once it breaks?
|
||||
|
||||
### 6. Validation
|
||||
|
||||
Questions:
|
||||
|
||||
- What test, metric, screenshot, demo, or user behavior proves this works?
|
||||
- What would you check before trusting it?
|
||||
- What does done mean in observable terms?
|
||||
|
||||
### 7. Reversibility
|
||||
|
||||
Questions:
|
||||
|
||||
- What decision here is hardest to undo?
|
||||
- What backup, migration, rollback, or escape hatch exists?
|
||||
- What should be logged as an ADR or explicit tradeoff?
|
||||
|
||||
## Pressure Adaptation
|
||||
|
||||
### If Knowledge Is New
|
||||
|
||||
- Define one missing concept in 2-4 sentences before asking.
|
||||
- Avoid jargon unless you define it.
|
||||
- Ask fewer branching questions.
|
||||
- Focus on goals, constraints, and first principles.
|
||||
- Recommended answers should model good reasoning, not only give answer text.
|
||||
|
||||
### If Knowledge Is Working
|
||||
|
||||
- Ask normal tradeoff questions.
|
||||
- Surface alternatives.
|
||||
- Push for validation and smallest useful version.
|
||||
- Challenge vague words like "simple", "scalable", "good", "clean", or "fast".
|
||||
|
||||
### If Knowledge Is Expert
|
||||
|
||||
- Skip basics.
|
||||
- Ask sharper counterfactuals.
|
||||
- Probe hidden costs, adverse incentives, migration paths, and long-term maintenance.
|
||||
- Ask what evidence would change their mind.
|
||||
|
||||
### If Pressure Is Light
|
||||
|
||||
- Keep questions clarifying.
|
||||
- Use supportive framing.
|
||||
- Stop after top ambiguities are resolved.
|
||||
|
||||
### If Pressure Is Standard
|
||||
|
||||
- Challenge assumptions and tradeoffs.
|
||||
- Keep moving until implementation path is concrete.
|
||||
|
||||
### If Pressure Is Hard
|
||||
|
||||
- Be direct.
|
||||
- Name weak reasoning.
|
||||
- Ask about unpleasant edge cases.
|
||||
- Demand observable validation.
|
||||
- Still ask one question at a time.
|
||||
|
||||
## Recommended Answer Format
|
||||
|
||||
Every question includes:
|
||||
|
||||
```text
|
||||
Question: ...
|
||||
Recommended answer: ...
|
||||
Why it matters: ...
|
||||
```
|
||||
|
||||
Keep "Why it matters" to one sentence.
|
||||
|
||||
## When To Stop
|
||||
|
||||
Stop grilling when one of these is true:
|
||||
|
||||
- User says stop.
|
||||
- Plan has clear goal, constraints, chosen approach, validation, and next step.
|
||||
- Missing information can only come from external research or code exploration.
|
||||
- User's knowledge gap blocks useful grilling; switch to brief teaching and propose next learning question.
|
||||
|
||||
End with:
|
||||
|
||||
- Final decision or current best plan.
|
||||
- Remaining open questions.
|
||||
- Next concrete action.
|
||||
- Risks to watch.
|
||||
@@ -0,0 +1,582 @@
|
||||
---
|
||||
name: interface-kit
|
||||
description: |
|
||||
Authoritative guide for implementing stunning, accessible, performant UI. Synthesizes
|
||||
design engineering philosophy, accessibility standards, animation principles, spatial design,
|
||||
typography, color systems, and component craft into a single actionable reference.
|
||||
Complements the design-system skill (which covers DESIGN.md spec writing) by covering
|
||||
the HOW of implementation.
|
||||
Trigger phrases: "build UI", "create component", "landing page", "make it look good",
|
||||
"frontend", "design", "polish UI", "implement design", "make it beautiful",
|
||||
"UI implementation", "component styling", "animation", "accessibility"
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 10. Pre-Delivery Review
|
||||
|
||||
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,48 @@
|
||||
# caveman
|
||||
|
||||
Talk like smart caveman. Same brain, fewer tokens.
|
||||
|
||||
## What it does
|
||||
|
||||
Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts ~65-75% of output tokens with full accuracy preserved. Mode persists for the whole session until changed or stopped.
|
||||
|
||||
Six intensity levels:
|
||||
|
||||
| Level | What change |
|
||||
|-------|-------------|
|
||||
| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. |
|
||||
| `full` | Default. Drop articles, fragments OK, short synonyms. |
|
||||
| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. |
|
||||
| `wenyan-lite` | Classical Chinese register, light compression. |
|
||||
| `wenyan-full` | Maximum 文言文. 80-90% character reduction. |
|
||||
| `wenyan-ultra` | Extreme classical compression. |
|
||||
|
||||
Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part.
|
||||
|
||||
## How to invoke
|
||||
|
||||
```
|
||||
/caveman # full mode (default)
|
||||
/caveman lite # lighter compression
|
||||
/caveman ultra # extreme compression
|
||||
/caveman wenyan # classical Chinese
|
||||
stop caveman # back to normal prose
|
||||
```
|
||||
|
||||
## Example output
|
||||
|
||||
Question: "Why does my React component re-render?"
|
||||
|
||||
Normal prose:
|
||||
> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue.
|
||||
|
||||
Caveman (full):
|
||||
> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`.
|
||||
|
||||
Caveman (ultra):
|
||||
> Inline obj prop → new ref → re-render. `useMemo`.
|
||||
|
||||
## See also
|
||||
|
||||
- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions
|
||||
- [Caveman README](../../README.md) — repo overview, install, benchmarks
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||
---
|
||||
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
## Persistence
|
||||
|
||||
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
|
||||
|
||||
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||
|
||||
## Rules
|
||||
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations reader can't decode. Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
|
||||
Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
|
||||
|
||||
No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is.
|
||||
|
||||
Pattern: `[thing] [action] [reason]. [next step].`
|
||||
|
||||
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||
|
||||
## Intensity
|
||||
|
||||
| Level | What change |
|
||||
|-------|------------|
|
||||
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
|
||||
| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl) — prose words only, never real code symbols/function names. Strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate |
|
||||
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||
|
||||
Example — "Why React component re-render?"
|
||||
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||
- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。"
|
||||
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
|
||||
|
||||
Example — "Explain database connection pooling."
|
||||
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
|
||||
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
|
||||
|
||||
## Auto-Clarity
|
||||
|
||||
Drop caveman when:
|
||||
- Security warnings
|
||||
- Irreversible action confirmations
|
||||
- Multi-step sequences where fragment order or omitted conjunctions risk misread
|
||||
- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions)
|
||||
- User asks to clarify or repeats question
|
||||
|
||||
Resume caveman after clear part done.
|
||||
|
||||
Example — destructive op:
|
||||
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||
> ```sql
|
||||
> DROP TABLE users;
|
||||
> ```
|
||||
> Caveman resume. Verify backup exist first.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: fuck-slop
|
||||
description: >
|
||||
De-slop pass for any text: detects and erases the statistical fingerprints of
|
||||
AI writing (negative parallelism / "not X but Y", em-dash abuse, rule-of-three,
|
||||
false ranges, puffery vocabulary, uniform cadence, hedged both-sidesing) and
|
||||
rewrites the text into its target register — academic article, tweet, reddit
|
||||
post, email, blog, anything between. Use when the user says "fuck slop",
|
||||
"f*ck slop", "deslop", "de-slop this", "remove the AI tells", "humanize this",
|
||||
"make this not sound like AI", or invokes /fuck-slop. Also use before
|
||||
publishing any agent-drafted prose.
|
||||
---
|
||||
|
||||
# F*ck Slop
|
||||
|
||||
Strip every mark of AI writing from a text and make it good in its genre. Not "make it pass a detector" — make it read like a specific person with a specific point wrote it for a specific audience.
|
||||
|
||||
## Why this is a loop, not a style guide
|
||||
|
||||
The worst tells — above all the **"not X but Y"** family — are not vocabulary mistakes. They are emergent properties of how LLMs generate text: preference tuning rewards balanced, contrastive, comprehensive-sounding framing, so the contrast move is baked into the model's priors. Two consequences drive this skill's architecture:
|
||||
|
||||
1. **You cannot reliably see your own slop.** The same priors that produce the pattern make it invisible on re-read. Detection must be mechanical — regex against a fixed catalog — never "does this look AI to me?"
|
||||
2. **Rewriting reintroduces slop.** Ask a model to remove "it's not just X, it's Y" and it produces "this is less about X than Y" — the same move in a wig. So every rewrite gets re-scanned, and the loop runs until the scan is clean.
|
||||
|
||||
Workflow: **Scan → Diagnose → Rewrite by meaning → Re-scan → (repeat) → Register check.**
|
||||
|
||||
## Phase 0: Fix the target
|
||||
|
||||
Before touching the text, establish:
|
||||
|
||||
- **Genre and venue** — academic article, tweet, reddit post, LinkedIn, email, blog, docs, marketing. If not stated and not obvious from the text, ask. Genre decides which tells are fatal and what "good" means; see [references/voices.md](references/voices.md).
|
||||
- **Audience and stance** — who reads it, and what the author actually claims. Slop is what fills the space where a claim should be; you cannot remove it without knowing the claim.
|
||||
- **Constraints** — length limits, required citations, house style.
|
||||
|
||||
## Phase 1: Mechanical scan
|
||||
|
||||
Run the detection patterns from [references/tells.md](references/tells.md) against the text. If the text is in a file (or you can write it to a temp file), run the grep commands in that reference literally — the catalog is written as runnable `grep -Ein` patterns. Otherwise apply each pattern by hand, line by line.
|
||||
|
||||
Produce a finding list: line/sentence, matched pattern, tell category. Also run the two structural checks that regex can't fully catch:
|
||||
|
||||
- **Cadence**: flag any run of 3+ consecutive sentences within ±4 words of the same length, and any paragraph where every sentence has the same shape (subject–verb–elaboration).
|
||||
- **Formatting**: bold scattered through prose, emoji-decorated headers or bullets, "**Term:** definition" bullet lists, headers on a text too short to need them, a tidy intro–three-points–conclusion skeleton.
|
||||
|
||||
Report the findings to the user as a short table before rewriting (category, count, worst example). This is the diagnosis; the user should see what was wrong.
|
||||
|
||||
## Phase 2: Rewrite by meaning, not by frame
|
||||
|
||||
Go finding by finding. The cardinal rule: **never fix a pattern by paraphrasing the pattern.** Fix it by deciding what the sentence actually asserts, then asserting that.
|
||||
|
||||
### The "not X but Y" family — three-way triage
|
||||
|
||||
Every negative parallelism gets exactly one of these treatments:
|
||||
|
||||
1. **The negation is a strawman** (nobody believes X). Delete the X half entirely and assert Y directly, with whatever evidence the text has.
|
||||
- *"It's not just a tool, it's a fundamental shift in how teams work"* → *"Teams that adopted it stopped holding standups within a month."*
|
||||
2. **The contrast is real** (people genuinely hold X). Then earn it: name who holds X, say concretely why Y beats it. A real contrast survives being made specific; slop doesn't.
|
||||
3. **The sentence asserts nothing** (the contrast is decoration on an empty claim). Delete the whole sentence. Most cases are this one.
|
||||
|
||||
Banned escape hatches — these are the same move and count as new findings: "less about X than Y", "X matters, but Y matters more", "the real X is Y", "the question isn't X, it's Y", "X? Y." (rhetorical-question variant), and the em-dash variant "— not X, but Y".
|
||||
|
||||
### Everything else
|
||||
|
||||
- **Puffery and inflated vocabulary** (pivotal, seismic, testament, tapestry, landscape, delve…): replace with the plain word, or with the concrete fact the puffery was hiding. "Plays a vital role in" → "does".
|
||||
- **Rule-of-three lists**: keep the strongest item, cut the rest — unless all three carry distinct information, in which case keep them and break the rhythm (different lengths, different syntax).
|
||||
- **False ranges** ("from X to Y"): if you can't name a meaningful midpoint between X and Y, it's not a range — name the two things or cut one.
|
||||
- **Hedged both-sidesing** ("it's worth noting", auto-counterpoints, "while X, it's also true that Y"): commit. One opinion, stated, owned. A counterpoint stays only if the author genuinely concedes it.
|
||||
- **Uniform cadence**: vary deliberately. Follow a long sentence with a short one. Fragments are legal. Don't apply a formula (alternating long/short is its own tell) — read the paragraph aloud and break wherever the rhythm is metronomic.
|
||||
- **Low specificity**: replace "many companies" / "studies show" / "recent research" with the actual names, numbers, and dates — **only from the source text, the conversation, or verifiable research you actually do**. Never invent specifics. If the author needs to supply one, leave a marked placeholder: `[ADD: which study?]`.
|
||||
- **Stock skeleton**: kill throat-clearing openers ("In today's fast-paced world…"), summary conclusions ("In conclusion… Ultimately…"), and engagement-bait endings ("What do you think?"). Start where the point starts; stop when it's made.
|
||||
|
||||
### What not to do — overcorrection is also slop
|
||||
|
||||
- No fake typos, forced slang, or manufactured "voice". Humanizer-tool output is its own genre of slop.
|
||||
- Em dashes are not banned. Humans use them. The tell is density and the double-dash "— not X, but —" move. Budget: at most one em dash per ~150 words, never two in a sentence.
|
||||
- Don't trade precision for personality in academic or technical text. There, de-slopping means cutting puffery and committing to claims — not adding attitude.
|
||||
- Preserve the author's meaning, claims, and facts exactly. This is a style pass, not a content edit. Flag, don't silently fix, anything that looks factually wrong.
|
||||
|
||||
## Phase 3: Verify loop
|
||||
|
||||
Re-run the full Phase 1 scan **on your rewritten text**. This step is not optional and not a formality — expect your own rewrite to contain new tells, because the model writing it has the same priors that created them. Fix and re-scan until a pass produces zero pattern hits and the cadence check passes. Cap at 4 passes; if a pattern survives 4 passes, rewrite that sentence from scratch starting from its bare claim ("what fact or opinion is this sentence for?").
|
||||
|
||||
## Phase 4: Register check
|
||||
|
||||
Check the clean text against its genre profile in [references/voices.md](references/voices.md): right length, right formality, right person, genre-specific tells gone (e.g. on reddit: no bold, no bullet essay; in academic prose: no first-person hot takes added). Then the final test — read it aloud. Anywhere you wouldn't say it to the actual audience, rewrite that sentence.
|
||||
|
||||
Deliver: the rewritten text, plus a brief change log (categories fixed, counts, and number of verify passes it took).
|
||||
@@ -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,209 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Calibrated grilling session for stress-testing a plan, design, idea, or decision. First assesses the user's topic knowledge, confidence, and desired pressure level, then asks one question at a time with recommended answers. Use when user says "grill me", "stress-test this", "challenge my plan", "interview me", or wants a plan probed without being overwhelmed.
|
||||
---
|
||||
|
||||
# Grill Me
|
||||
|
||||
Interview the user until the plan is clear, defensible, and ready for action.
|
||||
|
||||
This is not hostile debate. It is calibrated pressure. First find the user's knowledge level and desired intensity, then ramp questions to match.
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Ask one question at a time.
|
||||
- Give a recommended answer for every question.
|
||||
- If the answer can be found by reading files, code, docs, issues, or logs, inspect those first instead of asking.
|
||||
- Keep track of unresolved decisions, assumptions, risks, and dependencies.
|
||||
- Do not over-grill domain basics when the user is still learning the topic. Teach the missing frame briefly, then ask the next useful question.
|
||||
- Do not under-grill confident experts. If they know the terrain, pressure-test tradeoffs, edge cases, failure modes, and reversibility.
|
||||
- Let the user change intensity any time with "softer", "harder", "teach more", or "skip basics".
|
||||
|
||||
## Phase 1: Frame The Target
|
||||
|
||||
Identify what should be grilled before asking about comfort. If the topic is not clear, ask:
|
||||
|
||||
> What plan, design, or decision should I grill?
|
||||
>
|
||||
> Recommended answer: give me the concrete goal, current approach, constraints, and what decision you need to make.
|
||||
|
||||
If context already contains the plan, summarize it in 3-6 bullets and ask for correction:
|
||||
|
||||
> I think target is: [...]
|
||||
>
|
||||
> Recommended answer: "Yes, grill that" or "Adjust: ..."
|
||||
|
||||
## Phase 2: Calibration
|
||||
|
||||
Before grilling the topic, ask a short calibration question unless the user's level is already obvious from context.
|
||||
|
||||
Ask:
|
||||
|
||||
> Before I grill the plan: what is your current comfort with this topic, and how hard do you want the pressure?
|
||||
>
|
||||
> Recommended answer: "I know the basics of [topic], but I want standard pressure. Explain missing concepts briefly, then keep pushing."
|
||||
|
||||
Use the user's answer to set two dials:
|
||||
|
||||
### Knowledge Level
|
||||
|
||||
- **New** - user lacks core vocabulary or model of the domain.
|
||||
- **Working** - user understands basics and can discuss tradeoffs.
|
||||
- **Expert** - user knows domain deeply and wants sharper critique.
|
||||
|
||||
### Pressure Level
|
||||
|
||||
- **Light** - clarify goals, constraints, and missing context.
|
||||
- **Standard** - challenge assumptions, tradeoffs, and execution path.
|
||||
- **Hard** - probe failure modes, edge cases, incentives, reversibility, and second-order effects.
|
||||
|
||||
If the user does not answer calibration, default to:
|
||||
|
||||
- Knowledge: **Working**
|
||||
- Pressure: **Standard**
|
||||
|
||||
## Phase 3: Build The Decision Map
|
||||
|
||||
Create a private decision map while asking questions one at a time:
|
||||
|
||||
- Goal - what success means.
|
||||
- User or customer - who this affects.
|
||||
- Constraints - time, money, stack, team, policy, risk.
|
||||
- Options - obvious alternatives and why current option wins.
|
||||
- Dependencies - what must be true first.
|
||||
- Risks - what breaks, gets expensive, or becomes irreversible.
|
||||
- Validation - how user will know it worked.
|
||||
- Rollback - how to undo or recover.
|
||||
|
||||
Do not dump the full map unless user asks. Use it to choose the next question.
|
||||
|
||||
## Phase 4: Question Ladder
|
||||
|
||||
Move through this ladder. Stop early if the plan becomes clear enough or user asks to stop.
|
||||
|
||||
### 1. Goal Fit
|
||||
|
||||
Questions:
|
||||
|
||||
- What outcome matters most?
|
||||
- What would make this not worth doing?
|
||||
- What problem are we solving, and for whom?
|
||||
|
||||
### 2. Constraint Reality
|
||||
|
||||
Questions:
|
||||
|
||||
- What hard constraint cannot move?
|
||||
- What resource bottleneck decides the plan?
|
||||
- What assumption would kill the plan if false?
|
||||
|
||||
### 3. Option Pressure
|
||||
|
||||
Questions:
|
||||
|
||||
- What are the top two alternatives?
|
||||
- Why this approach over the boring one?
|
||||
- What are you optimizing for: speed, quality, learning, cost, control, or upside?
|
||||
|
||||
### 4. Execution Path
|
||||
|
||||
Questions:
|
||||
|
||||
- What is the smallest useful version?
|
||||
- What has to happen first?
|
||||
- What can be deferred without harming the goal?
|
||||
|
||||
### 5. Failure Modes
|
||||
|
||||
Questions:
|
||||
|
||||
- How does this fail in production or real use?
|
||||
- What edge case would embarrass the plan?
|
||||
- What part is hardest to observe once it breaks?
|
||||
|
||||
### 6. Validation
|
||||
|
||||
Questions:
|
||||
|
||||
- What test, metric, screenshot, demo, or user behavior proves this works?
|
||||
- What would you check before trusting it?
|
||||
- What does done mean in observable terms?
|
||||
|
||||
### 7. Reversibility
|
||||
|
||||
Questions:
|
||||
|
||||
- What decision here is hardest to undo?
|
||||
- What backup, migration, rollback, or escape hatch exists?
|
||||
- What should be logged as an ADR or explicit tradeoff?
|
||||
|
||||
## Pressure Adaptation
|
||||
|
||||
### If Knowledge Is New
|
||||
|
||||
- Define one missing concept in 2-4 sentences before asking.
|
||||
- Avoid jargon unless you define it.
|
||||
- Ask fewer branching questions.
|
||||
- Focus on goals, constraints, and first principles.
|
||||
- Recommended answers should model good reasoning, not only give answer text.
|
||||
|
||||
### If Knowledge Is Working
|
||||
|
||||
- Ask normal tradeoff questions.
|
||||
- Surface alternatives.
|
||||
- Push for validation and smallest useful version.
|
||||
- Challenge vague words like "simple", "scalable", "good", "clean", or "fast".
|
||||
|
||||
### If Knowledge Is Expert
|
||||
|
||||
- Skip basics.
|
||||
- Ask sharper counterfactuals.
|
||||
- Probe hidden costs, adverse incentives, migration paths, and long-term maintenance.
|
||||
- Ask what evidence would change their mind.
|
||||
|
||||
### If Pressure Is Light
|
||||
|
||||
- Keep questions clarifying.
|
||||
- Use supportive framing.
|
||||
- Stop after top ambiguities are resolved.
|
||||
|
||||
### If Pressure Is Standard
|
||||
|
||||
- Challenge assumptions and tradeoffs.
|
||||
- Keep moving until implementation path is concrete.
|
||||
|
||||
### If Pressure Is Hard
|
||||
|
||||
- Be direct.
|
||||
- Name weak reasoning.
|
||||
- Ask about unpleasant edge cases.
|
||||
- Demand observable validation.
|
||||
- Still ask one question at a time.
|
||||
|
||||
## Recommended Answer Format
|
||||
|
||||
Every question includes:
|
||||
|
||||
```text
|
||||
Question: ...
|
||||
Recommended answer: ...
|
||||
Why it matters: ...
|
||||
```
|
||||
|
||||
Keep "Why it matters" to one sentence.
|
||||
|
||||
## When To Stop
|
||||
|
||||
Stop grilling when one of these is true:
|
||||
|
||||
- User says stop.
|
||||
- Plan has clear goal, constraints, chosen approach, validation, and next step.
|
||||
- Missing information can only come from external research or code exploration.
|
||||
- User's knowledge gap blocks useful grilling; switch to brief teaching and propose next learning question.
|
||||
|
||||
End with:
|
||||
|
||||
- Final decision or current best plan.
|
||||
- Remaining open questions.
|
||||
- Next concrete action.
|
||||
- Risks to watch.
|
||||
@@ -0,0 +1,582 @@
|
||||
---
|
||||
name: interface-kit
|
||||
description: |
|
||||
Authoritative guide for implementing stunning, accessible, performant UI. Synthesizes
|
||||
design engineering philosophy, accessibility standards, animation principles, spatial design,
|
||||
typography, color systems, and component craft into a single actionable reference.
|
||||
Complements the design-system skill (which covers DESIGN.md spec writing) by covering
|
||||
the HOW of implementation.
|
||||
Trigger phrases: "build UI", "create component", "landing page", "make it look good",
|
||||
"frontend", "design", "polish UI", "implement design", "make it beautiful",
|
||||
"UI implementation", "component styling", "animation", "accessibility"
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 10. Pre-Delivery Review
|
||||
|
||||
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,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"caveman": {
|
||||
"source": "JuliusBrussee/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/caveman/SKILL.md",
|
||||
"computedHash": "b0e7741886c0c32270cedc1a2fc21c2879d90964d3ce00f24952de7064613404"
|
||||
},
|
||||
"fuck-slop": {
|
||||
"source": "JuliusBrussee/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/fuck-slop/SKILL.md",
|
||||
"computedHash": "3332e0e40b1611ff8833111f2aa9bfc35095a60b6f201a63b9a5771e6046d552"
|
||||
},
|
||||
"grill-me": {
|
||||
"source": "JuliusBrussee/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/grill-me/SKILL.md",
|
||||
"computedHash": "084fb183af7cd0000bdf8719601190d9f5113b19289da61a315e684f693717ee"
|
||||
},
|
||||
"interface-kit": {
|
||||
"source": "JuliusBrussee/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/interface-kit/SKILL.md",
|
||||
"computedHash": "3990d2fa28800ba93aa32ebfbb2b7277e0bd368111b85363bfe943fd19bb3b3a"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user