## Tracejam (test 05) defects fixed
- Horizontal scroll suppressed via `html { overflow-x: clip }` + `body` fallback
- Navbar items vertically centered (`align-items: center`, `line-height: 1`)
- Wordmark switched from Geist to Fraunces (`--font-wordmark`) — different register from body
- Hero highlight raised from baseline to mid-x-height — reads as highlighter, not fat underline
- Browser-frame chrome dots get `aria-hidden="true"`
## Skill hardening — three new slop-test gates (35 → 38)
- **Gate 36**: page must not horizontally scroll on any viewport 320–1920 px
- **Gate 37**: decorative effects on text (highlighter / accent stroke / mark) must be visually verified at x-height, not baseline
- **Gate 38**: interactive bars must declare `align-items: center` + `line-height: 1`
## New reference rules
- `layout-and-space.md` § Page-edge clipping — global clip is mandatory under any clipped-edge enrichment
- `typography.md` § Wordmark / logo — wordmark may use a different display face; should on Bento / Stat-Led / Workbench / Marquee Hero
- `microinteractions.md` § When to ship motion by default — default-on for SaaS-shaped archetypes, default-off for Editorial / Manifesto / Letter / Quote-Led / Long Document; six recipes documented (number reveal, pricing lift, marquee, stagger, recommended-tier pulse, CTA hover)
- `macrostructures.md` § SaaS page sequence — hero → social proof → features → testimonials → pricing → FAQ → CTA → footer; with voice rules (real prices, specific testimonials, conversational FAQ)
## Meridian Manifesto (test 04) extended
7 sections → 11 sections. Added § What we refuse (anti-list with strikethrough), § Working rules (7 numbered operational principles), § The practice (3-paragraph narrative with drop-cap), § What we read (annotated bibliography with 8 titles). Voice consistent with the original manifesto — declarative, no testimonials, no pricing.
## Two new SaaS-focused tests
- **Test 07 — Foundry** (Stat-Led + Newsprint). SOC2 / ISO27001 compliance automation. Hero is one giant number ("847" — animated counter from 0). 9 sections incl. 3-tier pricing, 3 testimonials with role + company, 8-question FAQ. Pricing card hover-lift + recommended-tier pulse-once.
- **Test 08 — Cohort** (Marquee Hero + Salon). Cohort-based courses platform. Continuous-scroll marquee of course titles. Alternating-side feature rows with hand-built CSS visualisations (card-stack, hours grid, thread, report). Stagger-reveal on testimonials. 2-tier pricing, 6-question FAQ, centered Salon footer.
Both tests respect prefers-reduced-motion. Both pass the 38-gate slop test. Both demonstrate the new SaaS page sequence.
## Gallery thumbnails
`site/_tests/_thumbs/01–08.png` — eight 1024×640 screenshots captured via Chrome headless. Gallery cards on the landing page now show real visual previews above the text. Cards inherit theme tokens (radius, rule, shadow) so switching the landing page's theme reskins the card frames around the screenshots. Hover scales the thumb 1.025× over 500 ms; respects prefers-reduced-motion.
Section title updated: "Six pages, six different shapes." → "Eight pages, eight different shapes."
## SKILL.md version bump
0.5.0 → 0.6.0.
## Files
- 13 modified, 6 created (tests 07 & 08 directories), 8 thumbnails added.
- ~840 lines added, ~25 removed.
- All skill files mirrored into `~/.claude/skills/hallmark/`.
## Deferred to next session
- **Study + redesign combined verb** — single-shot screenshot-to-rebuilt-page.
- **Auto-generated OG cards** — Node + Puppeteer utility for 1200×630 PNG from macrostructure stamp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
Microinteractions
The single biggest gap in 2026's anti-slop canon. Most skills correct typography and colour; very few correct the small, repeated moments where an interface either feels designed or feels generated. This file is the correction.
A microinteraction is one event with four parts: trigger → rules → feedback → loops/modes (Saffer). Get any of those wrong and the interface feels uncrafted. Ship them all right and the interface feels made — even when nothing else is unusual.
Principles
- Motion has intent or motion is cut. Every animation must clarify, guide, or confirm. If you cannot name what a transition communicates, it is decoration. Decoration is slop.
- Silent success. A successful action does not deserve a "Done!" toast. If the user sees the result, they don't need a confirmation. Reserve toasts for failures and for actions that hide their own effect.
- Optimism with rollback. Update the UI immediately on user action. Send the request in the background. If it fails, animate the rollback and offer Undo. Round-trip latency is a perception killer.
- Restraint, not restraint-as-personality. Hallmark is not "no motion." Hallmark is the right motion in the right place. A drag handle that springs into focus on grab is good. A page where every card pulses on hover is slop.
- Reduced motion is a first-class state, not an afterthought. Every interaction defines its reduced-motion behaviour explicitly. Default is: collapse spatial motion to opacity crossfade, keep duration ≤ 150ms, preserve functional state changes.
- Keyboard first, hover second. Every hover affordance has a focus equivalent. No interaction is hover-only.
When to ship motion by default
The skill biases toward motion-cut. But certain archetypes feel broken without motion — they're so visually busy (or so number-led) that complete stillness reads as a screenshot rather than an interface. For these archetypes, ship 2–3 small purposeful microinteractions automatically, without waiting for the user to ask.
Default-on archetypes: Bento Grid · Stat-Led · Workbench · Marquee Hero · Conversational FAQ
Default-off archetypes: Editorial · Manifesto · Letter · Quote-Led · Type Specimen · Long Document · Index-First · Letter
For default-on, pick two or three from this menu (never more than three primitives per page):
| Microinteraction | When to ship | Recipe |
|---|---|---|
| Number reveal | Stat-Led hero, headline numbers anywhere | IntersectionObserver fires on viewport entry; requestAnimationFrame counts from 0 to target over 1.2–1.6 s with --ease-out. Reduced-motion: skip animation, render final value. |
| Pricing card lift | Pricing tier cards | translateY(-3px) + shadow upgrade on :hover, 180 ms --ease-out. Active state: drop back to translateY(0) over 60 ms (the press). |
| CTA hover lift | Primary CTA buttons | translateY(-1.5px) + background-fade. 200 ms --ease-out. Active state at 60 ms. |
| Marquee scroll | Marquee Hero, customer-logo strip | @keyframes marquee translateX(-100%) over 40–60 s, infinite. Pauses on hover. Reduced-motion: stops the scroll, shows the first three items. |
| Stagger reveal | Testimonials, feature cards, gallery | IntersectionObserver fires on each card; 100 ms stagger; opacity 0 → 1 + translateY(8px → 0); --ease-out 400 ms. One-shot only — never re-fires on scroll. |
| Recommended-tier pulse | The middle pricing tier | One-shot @keyframes pulse-border 2 s, runs once on viewport entry. Subtle: opacity 0.4 → 1 → 0.4 on the border. Don't loop. |
| Caret blink | Terminal hero, code mockup | @keyframes blink 1 s steps(2) infinite on a 1ch-wide block. Reduced-motion: solid block, no blink. |
| Number tick on data update | Dashboard live values | See Number tick recipe below. |
Hard rules for default-on motion
- Every animation respects
prefers-reduced-motion: reduce— either skip entirely or run at 0.01 s. - No more than three distinct animation primitives per page. A counter + a hover-lift + a marquee = three. Don't add a fourth. The temptation to layer "just one more" is the slop pull.
- No scroll-linked animations on viewports below 40 rem (existing mobile rule,
component-cookbook.md§ Mobile collapse). - No animation longer than 2 s except continuous loops (marquee, ambient breathing, caret blink).
- The "if I removed this animation, would anyone notice?" test still applies — but for the default-on set, the answer is "yes, the page would feel screenshot-stiff and the brand would feel thin."
What never gets default motion
- Body text reveals on scroll. Reading is not a cinematic experience.
- Background gradient shifts. Distracting.
- Cursor followers. Always slop.
- Section-by-section fade-up-stagger. Pick one orchestrated entrance, not twelve.
- Tab content sliding sideways. Crossfade only (see Tab change recipe below).
When the page is default-off (Editorial, Manifesto, etc.), motion is opt-in — the user must ask. Stillness is the brand on those pages.
The timing canon
Pick from these durations. Do not invent new ones.
| Bucket | Use for |
|---|---|
| 80–120 ms | Instant feedback (button press tick, checkbox state, keystroke echo). The brain reads anything in this window as immediate. |
| 150–200 ms | Hover state transitions, focus rings appearing, single-property fades, tooltip appears (with delay before — see below). |
| 250–300 ms | Modal / dropdown / sheet opens, content fades in, validation icon scales in, tab content crossfade. |
| 400–500 ms | Toast slides in, page-load section reveal, complex multi-property transitions, accordion open. |
| 0 ms | The right answer surprisingly often. Focus state, keyboard navigation, error appearance — many things should not animate at all. |
Exit durations are 60–75% of the corresponding entrance. A 300ms enter pairs with a 200ms exit. Never the reverse.
The easing canon
Three curves cover ~90% of UI motion. Tokenise them and never freestyle.
:root {
/* Entering elements — decelerate into place */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
/* Exiting elements — accelerate away */
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
/* State toggles — symmetrical */
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
/* Material 3 standard alternative — slightly less aggressive */
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
}
Spring physics replaces eases for physical interactions only — drag-and-drop release, swipe-to-dismiss, picker wheel snap, satisfying button press scale-bounce. Otherwise: ease.
| Spring config | Feel | Use for |
|---|---|---|
stiffness: 50, damping: 20 |
Gentle, no overshoot | Calm reveals; almost an ease |
stiffness: 180, damping: 22 |
Snappy, slight overshoot | Drag release; toggle handle |
stiffness: 280, damping: 26 |
Stiff, minimal bounce | Picker snap; haptic-like button press |
stiffness: 400, damping: 40 |
Very stiff, no bounce | Position corrections; not a spring per se |
Banned curves: ease (the browser default — flat and uncrafted), linear except for progress bars and infinite loaders, anything with overshoot above ~110% (cubic-bezier(0.34, 1.56, 0.64, 1) and friends). Bounce is dated and signals templated work.
Recipes
Each recipe: trigger, what changes, duration, easing, accessibility note. If a recipe is missing here, return to the principles and derive it from them.
Button press
Trigger: pointer down. Changes: transform: scale(0.98) on press, base styling on release. Duration: 100ms in, 150ms out. Easing: in --ease-in, out --ease-out. A11y: focus ring stays visible; never animate the focus ring's existence.
.btn {
transition: background-color var(--dur-short) var(--ease-out),
transform 100ms var(--ease-out);
}
.btn:hover { background: var(--color-ink); color: var(--color-paper); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }
Input focus + label float
Trigger: focus event. Changes: border-bottom colour, label slides up + shrinks, optional subtle background tint. Duration: 200ms. Easing: --ease-out. Critical: the change happens before the user types — Stripe / Linear use this to confirm the field is alive. A11y: :focus-visible only, not :focus; respect reduced-motion by removing the slide and keeping only the colour change.
Form validation
Trigger: blur after the field has been touched once (the "touched" pattern), then re-validate on every input. Never validate on every keystroke from the start — it's hostile. Changes: icon scales in (200ms --ease-out), border tints, helper text replaces. Three-part error message: what broke, why, how to fix.
Toast notification
Trigger: action completes (or fails). Stack at one viewport corner; new toasts push existing ones in one direction; existing toasts do not reposition when a new one arrives. Duration: 400ms slide-in --ease-out, 4–6s dwell, 300ms slide-out --ease-in. Pause auto-dismiss on hover/focus. Use sparingly: if the action's effect is visible (a row was deleted; you can see the row is gone), no toast. Errors always get a toast with retry/undo.
Modal open / close
Trigger: explicit user action (click, key shortcut). Backdrop fades 300ms --ease-out. Content scales 0.96 → 1.0 + opacity 0 → 1, 300ms --ease-out. Close: 220ms --ease-in, scale 1.0 → 0.98, opacity → 0. Use the native <dialog> element — it handles focus trap and ::backdrop for free. inert on background. First focus to first interactive element, not the close button. Reduced motion: opacity-only crossfade, 150ms.
Dropdown / menu
Trigger: click or key shortcut. Open: 180ms --ease-out, optional 30ms-stagger items if there are ≤ 8 of them. Close: 140ms --ease-in. Light-dismiss on outside click and Escape. Use the Popover API where available. Anchor positioning: flip when within 16px of viewport edge.
Tooltip
Trigger: mouse hover (with 800–1000ms delay to prevent flash on casual movement) OR keyboard focus (with 0ms delay — keyboard users reached this deliberately, never delay them). Animation: 150ms --ease-out opacity. WCAG 1.4.13: tooltip must be hoverable (you can move pointer onto it without it disappearing), persistent (doesn't disappear on accidental movement), and dismissible (Escape).
Tab change
Trigger: click or arrow-key. Underline slides transform: translateX() + width transition, 250ms --ease-out. Outgoing content fades 100ms --ease-in, incoming fades 150ms --ease-out with a 50ms delay. Never animate the tab content's height — animate grid-template-rows: 0fr → 1fr if the tabs change height.
Number tick
Trigger: data loaded. Counter increments from 0 to value over 400ms with --ease-out applied to the value, not the element. Use Intl.NumberFormat for locale-correct separators. A11y: announce the final value with aria-live="polite", not every tick. Reduced motion: skip the tick, show the final value.
Copy-to-clipboard
Trigger: click. Changes: button label swaps to "Copied" with a check icon; revert after 2.5s. No toast. The label change is the feedback. Restore on mouseleave if user moves away sooner.
btn.addEventListener("click", async () => {
await navigator.clipboard.writeText(value);
btn.dataset.state = "copied";
setTimeout(() => delete btn.dataset.state, 2500);
});
.copy-btn[data-state="copied"] .copy-btn__label::after { content: " ✓ Copied"; }
.copy-btn[data-state="copied"] .copy-btn__label > * { opacity: 0; }
Drag handle
Trigger: hover (after 1–2s delay — Notion's pattern). Changes: handle reveals via opacity transition, cursor switches to grab. On grab: cursor grabbing, ghost element at 50% opacity follows pointer, drop indicator (1px line, accent colour) tracks the nearest valid drop target. Spring stiffness 280 / damping 26 on release-snap. A11y: arrow-key reorder when the row is focused; announce drag state with aria-live.
Optimistic update with rollback
Trigger: any action with a known-correct local prediction (toggle, like, archive, reorder). Changes: state mutates immediately; the row visibly updates. Async request fires. On success: nothing happens — silent success is the marker of taste. On failure: 200ms colour rollback animation + a toast with one Undo button. The toast does not auto-dismiss while the user might still want it.
const prevState = item.completed;
item.completed = !prevState; render();
try {
await api.update(item);
} catch {
item.completed = prevState; render();
toast({ tone: "error", message: "Couldn't save.", action: { label: "Try again", run: retry } });
}
Search-as-you-type
Trigger: input event. Debounce by 250ms before requesting; while debouncing, show a subtle indicator (border opacity or label colour shift). Highlight matches in results with <mark>. A11y: announce result count with aria-live="polite" after debounce settles, never per keystroke.
Command palette navigation
Trigger: ⌘K or /. Open: instant, no animation. Arrow-keys move selection — the indicator transitions between rows (120ms --ease-out on the highlight's transform: translateY()), but the items themselves don't move. Enter selects. Escape closes. Items stagger-fade in on first open only, never on filter change. The text input stays focused throughout. This is the Linear / Raycast / Vercel pattern.
Page-load reveals
One orchestrated entrance. Stagger by DOM index, capped at ~500ms total. Use IntersectionObserver, never scroll listeners. After first reveal, no more on-scroll animations — let the page just be there. Theme-specific themes (Atelier, Salon, Newsprint) skip reveals entirely; that is correct, not a bug.
The named tells (what AI defaults produce)
These are the microinteraction signatures of generated code. The slop test in SKILL.md checks for them; treat any one as a critical finding.
transition-all. Every property animating, including ones that should be instant (visibility, display, focus rings). Always specify the properties. The class is banned in Hallmark output.- Universal
hover:scale-105. Every card lifts on hover, with no shadow change, no easing specified, no purpose. AI's reflexive "make it interactive" gesture. - Bouncy overshoot easings.
cubic-bezier(0.34, 1.56, 0.64, 1)and friends on UI elements. Tasteless 2018-throwback. Reserve overshoots for genuine physical interactions (drag release). - Multiple simultaneous hover effects. A card that translates, scales, shadows, colour-shifts, and rotates on hover. Pick one signal.
- Animated gradient backgrounds on hover. The gradient slides through colour space. Distracting, expensive, communicates nothing.
- Glow halos on text. Heavy
text-shadowfor "neon" — destroys contrast, hurts legibility. - Cursor-follower dots. A trailing dot that lags behind the pointer. Adds nothing; triggers vestibular issues.
- Custom cursors on every interactive element. Conflicts with OS conventions; users learn nothing about what's clickable.
- Auto-rotating carousels with no controls. WCAG 2.2.2 violation. Always.
- Parallax on scroll. Different layers moving at different speeds. Vestibular trigger; rarely serves the content.
transitionapplied to layout properties. Animatingwidth,height,padding,margin,top,left. Triggers reflow on every frame. Usetransformorgrid-template-rows: 0fr → 1fr.- Universal scroll-triggered fade-up-stagger. Every section fades in on intersection. Page never settles. Pick one orchestrated entrance.
- Celebratory success toasts. "Done!" when the user just saved a thing they can see was saved. Silent success is taste.
- Confirmation dialogs for reversible actions. "Are you sure you want to delete this?" Replace with optimistic delete + Undo toast.
- Spinners with no minimum visible time. Spinner flashes on/off when the action completes in 80ms. Either delay showing it (150ms) or set a minimum visible duration (300ms).
- Tooltips with the same delay on hover and focus. Hover should delay 800–1000ms; focus should appear immediately. They are different intents.
- Focus rings that animate in. The ring fades in over 200ms, leaving keyboard users without an indicator at the start of the transition. Focus rings appear instantly. Always.
- Color-only state change. A field turns red on error with no icon, no text, no border style change. Fails WCAG 1.4.1 and is unreadable for ~8% of men.
- Toasts that move existing content. New toast pushes the page down; dismissed toast lets it spring back. Stack toasts; don't shift layout.
- Hover delays on touch. A
:hoverstate that the touch user can never reach because there's no equivalent focus / tap behaviour.
Theme-aware microinteractions
Microinteractions adapt to the theme. The same button press is louder in Brutal than in Atelier. Apply a multiplier per theme:
| Theme | Duration scale | Easing flavour | Notes |
|---|---|---|---|
| Specimen | 1.0× | --ease-out |
Default. Restrained. |
| Midnight | 0.9× | --ease-out |
Snappy, technical. |
| Brutal | 0.75× | --ease-out (sharper) |
Fast, decisive. No spring. |
| Garden | 1.2× | --ease-out |
Calm. Springs welcome. |
| Atelier | 1.3× | --ease-out (very gentle) |
Generous; almost no movement. |
| Newsprint | 0× | none | Static. Print metaphor. |
| Terminal | 0× | none, except cursor blink | Print + monospace metaphor. |
| Manifesto | 0.7× | --ease-out (sharp) |
Snap into place. |
| Salon | 1.4× | --ease-out (very gentle) |
Softest. |
| Linen | 1.2× | --ease-out |
Calm. |
| Almanac | 0.85× | --ease-out |
Functional, like a reference book. |
| Sport | 0.7× | --ease-out (sharp) |
Quick, italic-energy. |
If the theme has duration scale 0×, you do not animate. The page does not move. That is a deliberate design choice; it is not broken.
Accessibility ground truth
Every recipe in this file must pass these checks before shipping.
prefers-reduced-motion: reduceis honoured. Spatial motion collapses to opacity crossfade ≤ 150ms. Functional state changes (progress bars, spinners) slow but remain.- Focus rings are 2–3 px, ≥ 3:1 contrast, never animated in/out, present on every interactive element via
:focus-visible. - Hit targets ≥ 44 × 44 CSS px on touch surfaces.
- No reliance on colour alone for state. Pair with icon, text, or pattern.
- No flashing above 3 Hz. Even subtle pulse animations need rate caps.
- Keyboard equivalents for every hover affordance. No exceptions.
aria-liveon async state updates, butpolitenotassertiveunless safety-critical.
When in doubt, cut
Most pages have too much motion, not too little. Before shipping, walk through every animation in your output and ask: what would happen if this animation were instant? If the answer is "nothing — the user wouldn't notice", remove the animation. If the answer is "the user would lose information about what changed", keep it.
Reaching for a static answer is a sign of taste. Reaching for more motion is the AI default.