astack-skills 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/deslop/scripts/slop-scan.mjs +6 -0
- package/skills/web-design/SKILL.md +5 -4
- package/skills/web-design/references/craft-floor.md +6 -2
- package/skills/web-design/references/mobile.md +53 -0
- package/skills/web-design/references/motion.md +113 -29
- package/skills/web-design/references/worst-case-data.md +37 -0
- package/skills/web-graphics/SKILL.md +4 -1
- package/skills/web-graphics/kit/LICENSE +21 -0
- package/skills/web-graphics/kit/Sticker.tsx +40 -0
- package/skills/web-graphics/kit/fun.ts +739 -0
- package/skills/web-graphics/kit/icons.ts +738 -0
- package/skills/web-graphics/kit/motions.ts +177 -0
- package/skills/web-graphics/references/stickers.md +151 -0
- package/skills/web-graphics/scripts/fi.mjs +313 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
Motion and mobile craft from [Emil Kowalski's skills](https://github.com/emilkowalski/skills) and animated stickers from [fun-isometric](https://skills.klvn.dev/fun-isometric) by Kelvin Adithya, both MIT and credited where they are used.
|
|
6
|
+
|
|
7
|
+
- `web-graphics` makes animated sticker icons: flat-fill slabs with a thick outline that move on After Effects grade keyframe presets, for onboarding, empty states and rewards. The kit (`kit/fun.ts`, `motions.ts`, 24 worked icons, a React `<Sticker>`) is bundled, recolours from a brand's two colours, and plays live on canvas or exports to alpha webm and HEVC with a poster.
|
|
8
|
+
- New `fi.mjs` checks, sheets, frames and exports stickers. It is a dependency-free port of the original renderer: types are stripped per file and Chrome is driven over the DevTools protocol, so nothing installs Playwright at run time. Runs on Node 22.13+ or Bun.
|
|
9
|
+
- `web-design` motion reference rewritten around a decision order: whether it should animate at all (never keyboard or high-frequency actions), ease by what is happening (never `ease-in` on UI), durations under 300ms, press feedback, origin-aware popovers, interruptible transitions and `@starting-style`, gesture rules, and a symptom table for janky animation. Reviews come back as a Before / After / Why table.
|
|
10
|
+
- New `web-design` references: a mobile baseline (tap flash, stuck hover, `dvh`/`svh`, input zoom, safe areas, `touch-action`) and a worst-case data method with a catalogue of plausible values that break layouts.
|
|
11
|
+
- `slop-scan.mjs` flags `transition: all`, `ease-in` on UI, `scale(0)` entrances, disabled zoom and `100vh` app shells.
|
|
12
|
+
- Routing eval gains nine cases for stickers, janky motion, mobile feel and worst-case data.
|
|
13
|
+
|
|
3
14
|
## 0.5.0
|
|
4
15
|
|
|
5
16
|
Each skill was reviewed against tstack, pstack, gstack, impeccable, Anthropic's skills and the slop packs, and kept, rewritten or extended accordingly.
|
package/README.md
CHANGED
|
@@ -57,8 +57,8 @@ Both pull in the other skills as they need them.
|
|
|
57
57
|
| [astack](skills/astack/SKILL.md) | "astack this" | Takes a task from idea to a proven, review-ready PR, with a done-check at every stage. |
|
|
58
58
|
| [oneshot](skills/oneshot/SKILL.md) | "oneshot this" | The same, all the way: merged, deployed, verified live and announced. You call it by name. |
|
|
59
59
|
| [context](skills/context/SKILL.md) | "read the thread first" | Pulls the Slack thread, issue, PRs, git history and analytics that actually change the task into a short brief. |
|
|
60
|
-
| [web-design](skills/web-design/SKILL.md) | "this looks AI-generated" | Gives the UI a real direction, keeps a DESIGN.md so it sticks, animates with purpose, and checks it on a phone in light and dark. |
|
|
61
|
-
| [web-graphics](skills/web-graphics/SKILL.md) | "make a hero graphic" | Isometric scenes, flat illustrations, ASCII art, icons and share cards, built in code. |
|
|
60
|
+
| [web-design](skills/web-design/SKILL.md) | "this looks AI-generated" | Gives the UI a real direction, keeps a DESIGN.md so it sticks, animates with purpose, stress-tests it with worst-case data, and checks it on a phone in light and dark. |
|
|
61
|
+
| [web-graphics](skills/web-graphics/SKILL.md) | "make a hero graphic" | Isometric scenes, animated sticker icons, flat illustrations, ASCII art, icons and share cards, built in code. |
|
|
62
62
|
| [deslop](skills/deslop/SKILL.md) | "this sounds like AI" | Finds the tells in copy, UI and code, fixes them, and can learn your voice from your own writing. |
|
|
63
63
|
| [instrument](skills/instrument/SKILL.md) | "can we measure this" | Tracking that fires once, on real success, and a query you can actually trust. |
|
|
64
64
|
| [discoverability](skills/discoverability/SKILL.md) | "why doesn't ChatGPT mention us" | Checks what Google and AI crawlers really see, fixes it, measures before and after. |
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "astack",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"description": "Skills for Claude Code and Codex that take product work from the thread to a proven, live release.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Andrew Wilkinson",
|
|
@@ -65,6 +65,12 @@ const RULES = [
|
|
|
65
65
|
["caps-eyebrow", "ui", "info", /\buppercase\b[^"'`]*\btracking-(?:widest|\[0\.[1-9]\d*em\])|\btracking-(?:widest|\[0\.[1-9]\d*em\])[^"'`]*\buppercase\b|text-transform:\s*uppercase;\s*letter-spacing:\s*0?\.[12]\d*em/g, "Tracked-out all-caps label; the eyebrow above every heading."],
|
|
66
66
|
["generic-cta", "ui", "info", />\s*(?:Get Started|Learn More|Start (?:your )?(?:free )?trial|Join the waitlist|Explore (?:now|features))\s*</g, "Generic CTA label; name what happens next ('Create a listing', 'See rates')."],
|
|
67
67
|
["emoji-in-ui", "ui", "medium", />[^<{]*?(?:🚀|✨|🔥|💡|⚡|🎉|👋|💪|🙌|📈|🎯)[^<]*?</gu, "Emoji in interface copy; usually stands in for an icon system or a point of view."],
|
|
68
|
+
// Motion and mobile details that make an interface feel slightly off.
|
|
69
|
+
["transition-all", "ui", "medium", /\btransition-all\b|transition:\s*all\b/g, "Transitions every property, including layout ones nobody meant to animate. Name them: transform, opacity."],
|
|
70
|
+
["ease-in-ui", "ui", "info", /\bease-in\b(?!-out)|(?:transition|animation)(?:-timing-function)?:[^;]*\bease-in\b(?!-out)/g, "ease-in starts slowly, exactly when the user is watching; UI enters and exits on ease-out."],
|
|
71
|
+
["scale-from-zero", "ui", "info", /\bscale-0\b|scale\(\s*0\s*\)|\bscale:\s*0\b/g, "Enters from nothing. Start at scale 0.95 with opacity 0."],
|
|
72
|
+
["zoom-disabled", "ui", "high", /user-scalable\s*=\s*(?:no|0)|maximum-scale\s*=\s*1(?:\.0)?\b/g, "Disables pinch zoom for everyone. Fix the cause instead: inputs at 16px stop iOS focus zoom."],
|
|
73
|
+
["vh-shell", "ui", "info", /\b(?:min-)?h-screen\b|(?:min-)?height:\s*100vh\b/g, "100vh is taller than the visible area on phones; use dvh for app shells and svh for heroes."],
|
|
68
74
|
["narrating-comment", "code", "info", /(?:\/\/|#)\s*(?:This (?:function|method|component|code)|Function to|Helper (?:function )?to|Initiali[sz]e the|Define the|Create a new|Loop through|Check if|Return the|Import (?:the )?(?:necessary|required))\b/g, "Comment paraphrases the next line; keep comments that explain why."],
|
|
69
75
|
["placeholder-code", "code", "high", /(?:\/\/|#)\s*(?:TODO:? implement|Add (?:your|more) .{0,30} here|Rest of (?:the )?code|\.\.\. ?existing code|implementation goes here)|\bthrow new Error\(["']Not implemented["']\)/gi, "Placeholder left in shipped code."],
|
|
70
76
|
["swallowed-error", "code", "medium", /catch\s*(?:\(\s*\w*\s*\))?\s*\{\s*\}|except(?:\s+\w+)?:\s*\n\s*pass\b/g, "Empty catch hides failures; handle, log with context, or let it throw."],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: web-design
|
|
3
|
-
description: Design and build web interfaces that look like a specific product made them, not a template, including their animation and transitions. Use for new pages, redesigns, landing pages, brand refreshes, component work, "make this look better", "this looks AI-generated", "animate this", "make it feel smoother", or any UI change where hierarchy, typography, colour, layout, motion or responsive behaviour matter. Keeps a DESIGN.md so the direction survives between sessions.
|
|
3
|
+
description: Design and build web interfaces that look like a specific product made them, not a template, including their animation and transitions. Use for new pages, redesigns, landing pages, brand refreshes, component work, "make this look better", "this looks AI-generated", "animate this", "make it feel smoother", "the animation is janky", "it feels wrong on my phone", "try it with long names", or any UI change where hierarchy, typography, colour, layout, motion or responsive behaviour matter. Keeps a DESIGN.md so the direction survives between sessions.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Web design
|
|
@@ -45,13 +45,13 @@ When the user gives references, study them properly: [reference study](reference
|
|
|
45
45
|
## 3. Build in the product's system
|
|
46
46
|
|
|
47
47
|
- Use existing tokens, primitives and components. Add a token before adding a one-off value.
|
|
48
|
-
- Real content only: real names, long names, real numbers, empty data. Never lorem ipsum, never invented metrics to fill a layout.
|
|
48
|
+
- Real content only: real names, long names, real numbers, empty data. Never lorem ipsum, never invented metrics to fill a layout. Before calling a data-driven screen done, run it on [worst-case data](references/worst-case-data.md): the longest plausible name, an unbreakable email, a count of 1 and of 1,284, an empty list, a missing avatar.
|
|
49
49
|
- Design the states with the screen: loading, empty, error, success, disabled, overflow. An empty state that looks broken is a bug.
|
|
50
50
|
- Hide what has nothing to say. Zero rows, zero balances and empty charts become an honest empty state or disappear; no walls of zeros.
|
|
51
51
|
- Headers are labels. A subtitle that re-explains the header gets deleted.
|
|
52
|
-
- Mobile is its own layout: reorder by priority, use horizontal snap rails where desktop uses grids, `dvh`/`svh` not `100vh`, inputs at 16px or larger, tap targets 44px.
|
|
52
|
+
- Mobile is its own layout: reorder by priority, use horizontal snap rails where desktop uses grids, `dvh`/`svh` not `100vh`, inputs at 16px or larger, tap targets 44px. Anything people use on a phone gets the [mobile baseline](references/mobile.md): no tap flash, hover gated to real pointers, press feedback on touch, safe areas, no zoom on focus.
|
|
53
53
|
|
|
54
|
-
Hold the line on the [craft floor](references/craft-floor.md) while building, not as a final pass. For any animation or transition, read [motion](references/motion.md) first:
|
|
54
|
+
Hold the line on the [craft floor](references/craft-floor.md) while building, not as a final pass. For any animation or transition, read [motion](references/motion.md) first: decide whether it should animate at all (never for keyboard or high-frequency actions), ease-out and under 300ms for UI, press feedback and origin-aware popovers, interruptible transitions, only `transform` and `opacity`, reduced motion handled, and watched as a filmstrip. The same reference diagnoses janky animation by cause.
|
|
55
55
|
|
|
56
56
|
## 4. Look at it
|
|
57
57
|
|
|
@@ -68,6 +68,7 @@ Open every screenshot and actually read it. Then go through, in this order:
|
|
|
68
68
|
3. **Hierarchy and rhythm**: more space above a heading than below it; consistent gaps within groups, larger between groups; one primary action per view.
|
|
69
69
|
4. **Generic**: run `deslop`'s scan or check [anti-patterns](references/anti-patterns.md). Would a stranger guess this product from a screenshot with the logo removed?
|
|
70
70
|
5. **Craft**: alignment to the pixel at 2x, optical balance of icons and text, tabular numbers in columns.
|
|
71
|
+
6. **Stress**: the worst-case fixture at the real container width, 320px and 200% text. Touch details need a real phone; say so if you had none.
|
|
71
72
|
|
|
72
73
|
Fix the worst problem, re-shoot, repeat. Compare against references at the same width and state. Two or three full loops is normal for new work.
|
|
73
74
|
|
|
@@ -34,14 +34,18 @@ The minimum for anything that ships. Check these while building, then once more
|
|
|
34
34
|
|
|
35
35
|
- Every interactive element has a visible focus style, a hover style on pointer devices, and a pressed state.
|
|
36
36
|
- Tap targets 44 by 44px on touch layouts, including icon buttons and close buttons.
|
|
37
|
+
- Hover styles sit inside `@media (hover: hover) and (pointer: fine)`; touch gets `:active` instead.
|
|
37
38
|
- Buttons say what happens ("Send invoice"), links say where they go. No "Click here", no bare "Submit" when there is a better verb.
|
|
38
39
|
- Disabled controls explain why when it is not obvious.
|
|
39
40
|
- Loading shows what is pending without implying success; skeletons match the real layout to avoid shift.
|
|
40
41
|
|
|
41
42
|
## Motion
|
|
42
43
|
|
|
43
|
-
- One signature ease and two or three durations, from tokens. No bounce or overshoot unless the brand is playful on purpose.
|
|
44
|
-
-
|
|
44
|
+
- One signature ease and two or three durations, from tokens. Ease-out for anything entering or leaving, never `ease-in`; UI motion under 300ms. No bounce or overshoot unless the brand is playful on purpose.
|
|
45
|
+
- Pressable things scale to about 0.97 on `:active`; popovers grow from their trigger; nothing enters from `scale(0)`.
|
|
46
|
+
- No animation on keyboard-driven or high-frequency actions.
|
|
47
|
+
- Only `transform` and `opacity` animate by default; never `transition: all`.
|
|
48
|
+
- Respect `prefers-reduced-motion`: movement goes, the end state is complete, never an invisible or half-animated element.
|
|
45
49
|
|
|
46
50
|
## Refuse unless the brief asks
|
|
47
51
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Feeling native on a phone
|
|
2
|
+
|
|
3
|
+
Most "it feels janky on mobile" reports are not design problems. They are a grey flash on tap, a hover that will not let go, a page that zooms into an input, or a button hidden under the URL bar. Each is one declaration. None of them reproduces in desktop device emulation, so a layout that passes at 390px in `shoot.mjs` can still feel like a website in someone's hand.
|
|
4
|
+
|
|
5
|
+
Adapted from [Emil Kowalski's mobile-native skill](https://github.com/emilkowalski/skills) (MIT).
|
|
6
|
+
|
|
7
|
+
## Baseline
|
|
8
|
+
|
|
9
|
+
Ship this before the first component of anything people will use on a phone:
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
|
|
13
|
+
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff">
|
|
14
|
+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0a0a0a">
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```css
|
|
18
|
+
html {
|
|
19
|
+
-webkit-tap-highlight-color: transparent;
|
|
20
|
+
-webkit-text-size-adjust: 100%;
|
|
21
|
+
}
|
|
22
|
+
input, textarea, select { font-size: 16px; }
|
|
23
|
+
button, a, [role="button"] { touch-action: manipulation; }
|
|
24
|
+
button, [role="button"], .tab, .chip { user-select: none; -webkit-user-select: none; }
|
|
25
|
+
@media (hover: hover) and (pointer: fine) {
|
|
26
|
+
/* every :hover rule lives here */
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Set the `theme-color` values to the colour at the very top of the page (the header), not the brand colour.
|
|
31
|
+
|
|
32
|
+
## Symptom table
|
|
33
|
+
|
|
34
|
+
| Symptom | Fix and why |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| Hover state stuck after a tap | Gate hover in `@media (hover: hover) and (pointer: fine)`. Touch fakes `:hover` on first tap and keeps it. Give touch its feedback through `:active`. |
|
|
37
|
+
| Grey or blue flash on tap | `-webkit-tap-highlight-color: transparent` once on `html`, then make sure every tappable thing has its own `:active` state. |
|
|
38
|
+
| Bottom button under the URL bar, or a hero that overflows | `100dvh` for app shells and bottom-pinned UI; `100svh` for heroes, which must not jump while scrolling. Never `100vh`. |
|
|
39
|
+
| Page zooms into an input and stays zoomed | Inputs at 16px or larger. Never `maximum-scale=1` or `user-scalable=no`; that disables zoom for everyone. |
|
|
40
|
+
| Wrong keyboard | `inputmode="numeric"` for codes, `inputmode="decimal"` for amounts, `type="email"`/`"tel"`, `autocapitalize="none"` on usernames and codes, `enterkeyhint` naming the action. |
|
|
41
|
+
| Taps feel slow | `touch-action: manipulation` removes the double-tap wait; style `:active` (or listen to `pointerdown`) so the response lands when the finger does, not when it lifts. |
|
|
42
|
+
| Pull-to-refresh or rubber-banding fights the app | `overscroll-behavior: none` on `html, body` for app-like surfaces; `overscroll-behavior: contain` on inner scrollers such as sheets and chat lists. Keep the default on documents. |
|
|
43
|
+
| Content stops short of the notch, or sits under the home bar | `viewport-fit=cover` in the viewport meta, then `env(safe-area-inset-*, 0px)` padding on fixed headers, tab bars, toasts and sheets. Without the meta tag the insets are all zero. |
|
|
44
|
+
| Long-press selects a button's label | `user-select: none` on controls only. Never on `body`: people copy addresses, order numbers and errors. |
|
|
45
|
+
| Swiping a carousel scrolls the page too | `touch-action: pan-y` on a horizontal gesture surface; prefer native `scroll-snap-type: x mandatory` over a hand-rolled carousel. |
|
|
46
|
+
| Status bar the wrong colour in one theme | One `theme-color` per colour scheme; update it from JS if the theme toggles by class. |
|
|
47
|
+
|
|
48
|
+
## Rules
|
|
49
|
+
|
|
50
|
+
- Detect capability with media queries (`hover`, `pointer`, `env()`, `dvh`), never user-agent sniffing or a width breakpoint standing in for "touch".
|
|
51
|
+
- Touch and mouse are not exclusive: tablets with trackpads, laptops with touchscreens. Write for both at once.
|
|
52
|
+
- Apply each fix where its reason applies. `user-select: none` on a button is right; on a paragraph it is a defect.
|
|
53
|
+
- Real hardware decides. Run the dev server on `0.0.0.0`, open it by LAN IP on a phone, and inspect through Safari's Develop menu or `chrome://inspect`. Say which fixes you verified from code and which still need a phone in the hand.
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
# Motion
|
|
2
2
|
|
|
3
|
-
Read this before adding or
|
|
3
|
+
Read this before adding, fixing or reviewing animation. Motion earns its place by explaining a change: where something came from, what caused it, what is now true. Everything else is decoration, and generated UI has too much of it. Agents also get the small choices wrong (an `ease-in` dropdown, a popover growing from its centre, a toast that restarts when another arrives), and those compound into an interface that feels slightly off.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The decision rules, values and performance diagnosis here draw on [Emil Kowalski's skills](https://github.com/emilkowalski/skills) (MIT).
|
|
6
|
+
|
|
7
|
+
### 1. Decide whether it animates
|
|
8
|
+
|
|
9
|
+
How often will someone see it?
|
|
10
|
+
|
|
11
|
+
| Frequency | Decision |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| 100+ times a day (keyboard shortcuts, command palette, list navigation by key) | No animation. |
|
|
14
|
+
| Tens of times a day (hovers, tab switches, row selection) | Remove or cut to a fast state change. |
|
|
15
|
+
| Occasionally (modals, drawers, toasts, popovers) | Standard animation. |
|
|
16
|
+
| Rarely or once (onboarding, first success, celebrations) | Room for delight. |
|
|
17
|
+
|
|
18
|
+
Never animate a keyboard-initiated action. Then name the purpose; valid ones are spatial consistency (a toast leaves the way it came, so swiping it away makes sense), state indication, explanation, feedback on press, and softening a change that would otherwise feel broken. "It looks nice" on something seen often is not a purpose.
|
|
19
|
+
|
|
20
|
+
### 2. Write the contract
|
|
6
21
|
|
|
7
22
|
For each animated thing, one line per field:
|
|
8
23
|
|
|
@@ -10,35 +25,96 @@ For each animated thing, one line per field:
|
|
|
10
25
|
- **Trigger**: input, state change, viewport entry, or a deliberate sequence.
|
|
11
26
|
- **From / to**: the resting states. The end state must be complete on its own.
|
|
12
27
|
- **Interruption**: what happens on a second click mid-animation: reverse, retarget or finish.
|
|
13
|
-
- **Reduced motion**: what shows instead
|
|
28
|
+
- **Reduced motion**: what shows instead (see [reduced motion](#reduced-motion-wiring)).
|
|
14
29
|
|
|
15
|
-
###
|
|
30
|
+
### 3. Pick the ease and the duration
|
|
16
31
|
|
|
17
|
-
|
|
32
|
+
Ease by what is happening:
|
|
18
33
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
- Entering or leaving: **ease-out**. It starts fast, so the response feels immediate.
|
|
35
|
+
- Moving or morphing while on screen: **ease-in-out**.
|
|
36
|
+
- Hover and colour changes: **ease**.
|
|
37
|
+
- Constant motion (progress, marquee): **linear**.
|
|
38
|
+
- Anything else: ease-out.
|
|
39
|
+
|
|
40
|
+
Never `ease-in` on UI. It spends its first frames barely moving, which is exactly when the user is watching; an ease-in dropdown at 200ms feels slower than an ease-out one at 200ms. The built-in CSS curves are weak. Use the project's tokens, or start from these and write them into DESIGN.md:
|
|
41
|
+
|
|
42
|
+
```css
|
|
43
|
+
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* enter, exit, press */
|
|
44
|
+
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* on-screen movement */
|
|
45
|
+
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* sheets and drawers */
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| Use | Duration |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| Press feedback | 100–160ms |
|
|
51
|
+
| Tooltips, small popovers | 125–200ms |
|
|
52
|
+
| Dropdowns, selects, tab indicators | 150–250ms |
|
|
53
|
+
| Modals, drawers, sheets | 200–500ms (larger travel takes longer) |
|
|
54
|
+
| Illustration and explainer beats | 400–700ms per beat |
|
|
55
|
+
|
|
56
|
+
UI motion stays under 300ms unless it travels far. Exits run faster than enters. Speed reads as performance: a 180ms select feels more responsive than a 400ms one, and a quick spinner makes the same wait feel shorter. One signature ease per product; bounce and overshoot only where the brand is playful on purpose, or for drag-to-dismiss.
|
|
57
|
+
|
|
58
|
+
Match the motion to the component's character: a dashboard is crisp and fast; a toast library can be a touch slower and softer if everything around it agrees.
|
|
59
|
+
|
|
60
|
+
### 4. Make it physical
|
|
26
61
|
|
|
27
|
-
|
|
62
|
+
- **Press**: every pressable element gets `:active { transform: scale(0.97) }` with a 100–160ms ease-out transition (0.95–0.98 range). Feedback on press, not on release.
|
|
63
|
+
- **Never from nothing**: enter from `scale(0.95)` plus `opacity: 0`, never `scale(0)`.
|
|
64
|
+
- **Origin-aware**: popovers, menus and tooltips scale from their trigger (`transform-origin: var(--transform-origin)` in Base UI and Radix, or computed from the trigger side). Modals stay centred.
|
|
65
|
+
- **Tooltips**: delay the first one; once one is open, neighbours open instantly with no animation.
|
|
66
|
+
- **Percent translates**: `translateY(100%)` moves an element by its own height, which is how sheets and toasts hide without knowing their size.
|
|
67
|
+
- **Asymmetric timing**: slow where the user is deciding (hold-to-confirm fills over 1–2s linear), fast where the system answers (release snaps back in 200ms ease-out).
|
|
68
|
+
- **Stagger**: 30–80ms between items, for entrances only, and never blocking input.
|
|
69
|
+
- **Crossfades that show two states**: add `filter: blur(2px)` for the duration of the swap so the eye reads one transformation. Keep blur small; it is expensive.
|
|
70
|
+
- **Hover**: gate hover motion behind `@media (hover: hover) and (pointer: fine)`, or touch screens keep it stuck after a tap.
|
|
28
71
|
|
|
29
|
-
###
|
|
72
|
+
### 5. Build it so it cannot get stuck
|
|
30
73
|
|
|
31
74
|
- Application state is the source of truth; animation follows it. Keep "open/closed" separate from "entering/exiting".
|
|
75
|
+
- Anything that can fire rapidly (toasts arriving, toggles) uses CSS **transitions**, which retarget from where they are; keyframes restart from zero. Springs keep velocity when interrupted, which makes them right for gestures.
|
|
76
|
+
- Enter without a mount flag using `@starting-style`:
|
|
77
|
+
|
|
78
|
+
```css
|
|
79
|
+
.toast { transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
|
|
80
|
+
@starting-style { opacity: 0; transform: translateY(100%); } }
|
|
81
|
+
```
|
|
82
|
+
|
|
32
83
|
- Rapid input produces one coherent outcome: no stacked timelines, no element left half-visible and still clickable.
|
|
33
84
|
- Cancel frames, timers and observers on unmount and navigation. Do not rely on `animationend`/`transitionend` for essential state; cancelled and reduced-motion paths never fire them.
|
|
34
|
-
- Animate `transform` and `opacity`. Measure before claiming smoothness.
|
|
35
85
|
- Re-key only the element whose meaning changed. Re-mounting a whole SVG replays its entrance on every click.
|
|
36
86
|
- On SVG groups that already have a `transform` attribute, animate CSS `translate`/`scale` properties so the authored transform survives.
|
|
37
|
-
- Swapping captions or panels must not move the controls: stack them in one grid cell (see [stable controls](#stable-controls-while-content-swaps)
|
|
87
|
+
- Swapping captions or panels must not move the controls: stack them in one grid cell (see [stable controls](#stable-controls-while-content-swaps)).
|
|
38
88
|
- Ambient loops pause offscreen (`IntersectionObserver`) and when the tab is hidden (`visibilitychange`). At most one ambient effect per screen.
|
|
39
|
-
-
|
|
89
|
+
- Never delay the actual action (navigation, submit) until an animation finishes.
|
|
90
|
+
|
|
91
|
+
### 6. Gestures
|
|
92
|
+
|
|
93
|
+
- Dismiss on velocity as well as distance: `Math.abs(distance) / elapsedMs > 0.11` is a flick, and a flick is enough.
|
|
94
|
+
- Past a boundary, apply damping (the further the drag, the less it moves) instead of a hard stop.
|
|
95
|
+
- Capture the pointer once a drag starts (`setPointerCapture`), and ignore extra touch points until it ends.
|
|
96
|
+
- Declare which axes the gesture owns with `touch-action` (`pan-y` on a horizontal carousel, `none` only on a surface that handles every direction).
|
|
97
|
+
- Decorative pointer-following goes through a spring (Motion's `useSpring`, or `{ type: "spring", duration: 0.5, bounce: 0.2 }`), never raw pointer coordinates. Functional readouts follow the pointer directly.
|
|
98
|
+
|
|
99
|
+
### 7. Keep every frame on time
|
|
100
|
+
|
|
101
|
+
The budget is 16.7ms a frame at 60Hz and 8ms at 120Hz, which most phones now are. Design against the smaller number and a mid-range phone, not the laptop.
|
|
102
|
+
|
|
103
|
+
| Symptom | Cause and fix |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| Stutters | Animating `width`, `height`, `top`, `left`, `margin` or `padding`. Use `transform` (`scale`, `translate`) and `opacity`. |
|
|
106
|
+
| Choppy shadow | `box-shadow` repaints every frame. Animate a `filter: drop-shadow()` or the opacity of a pseudo-element that holds the shadow. |
|
|
107
|
+
| Unexpected things animate | `transition: all`. List the exact properties. |
|
|
108
|
+
| Janks only during navigation or loading | JS-driven animation on a busy main thread. Move it to CSS or WAAPI (`element.animate`), which can run off the main thread. |
|
|
109
|
+
| Motion's `x`/`y`/`scale` drop frames under load | Shorthands run through CSS variables on the main thread. Animate a `transform` string instead. |
|
|
110
|
+
| Hand-rolled animation drops frames in React | `setState` per frame. Write to `ref.current.style` or a motion value. |
|
|
111
|
+
| Drag slows as the list grows | A CSS variable on the parent drives children's transforms, so every descendant recalculates. Set `transform` on the element directly. |
|
|
112
|
+
| Blur transition lags, worst in Safari | Keep animated blur at 2–5px, never above 20px. |
|
|
113
|
+
| Element shifts by a pixel at start or end | Only now add `will-change: transform`, on that element alone. |
|
|
40
114
|
|
|
41
|
-
|
|
115
|
+
Name the cause before changing code, and say which findings came from reading code and which from a recording. A performance trace in the browser beats rewriting by feel; report frame rates only when measured, with the device.
|
|
116
|
+
|
|
117
|
+
### 8. Watch it play
|
|
42
118
|
|
|
43
119
|
Stills cannot prove motion. Capture a filmstrip and look at the frames:
|
|
44
120
|
|
|
@@ -48,18 +124,28 @@ node <skill-dir>/scripts/shoot.mjs http://localhost:3000/ --widths 390 --click '
|
|
|
48
124
|
node <skill-dir>/scripts/shoot.mjs http://localhost:3000/ --widths 390,1440 # reduced motion: final states
|
|
49
125
|
```
|
|
50
126
|
|
|
51
|
-
Then in a real browser: trigger once; trigger rapidly; reverse mid-way; navigate away mid-animation; use keyboard and touch; enlarge text; switch to reduced motion. Fix flicker, jumps, clipped frames and stranded states, then re-check that case.
|
|
127
|
+
In the frames, check that colours cross cleanly rather than showing two states, the ease does not stop abruptly, the origin is right, and coordinated properties stay in step. Then in a real browser: trigger once; trigger rapidly; reverse mid-way; navigate away mid-animation; use keyboard and touch; enlarge text; switch to reduced motion. Slow it down 2–5x (DevTools Animations panel) when the feel is in doubt. Gestures need a real phone. Fix flicker, jumps, clipped frames and stranded states, then re-check that case.
|
|
128
|
+
|
|
129
|
+
### 9. Report
|
|
52
130
|
|
|
53
|
-
|
|
131
|
+
Report the contract, the tokens used, what you watched (filmstrip paths, cases exercised) and anything measured with its method. When reviewing someone's motion, give findings as one table:
|
|
132
|
+
|
|
133
|
+
| Before | After | Why |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| `transition: all 300ms` | `transition: transform 200ms var(--ease-out)` | Name the properties; 300ms is slow for UI |
|
|
136
|
+
| Popover `transform-origin: center` | `transform-origin: var(--transform-origin)` | It should grow from its trigger |
|
|
54
137
|
|
|
55
|
-
|
|
138
|
+
For an exported video, deliver the file and check duration, dimensions, crop and audio sync; a browser preview is not an export.
|
|
56
139
|
|
|
57
140
|
### Refuse
|
|
58
141
|
|
|
59
142
|
- Fade-up on every section. Pick one authored moment per page.
|
|
143
|
+
- Animation on keyboard-driven or high-frequency actions.
|
|
144
|
+
- `ease-in` on interface motion; `scale(0)` entrances; `transition: all`.
|
|
60
145
|
- Hover zoom on cards; pulsing dots without live data; marquees; parallax for its own sake.
|
|
61
146
|
- Fake progress that is not tied to real progress.
|
|
62
147
|
- Long staggers that make an ordinary list feel slow.
|
|
148
|
+
- `will-change` sprinkled as a fix before the cause is named.
|
|
63
149
|
|
|
64
150
|
## Lifecycle details
|
|
65
151
|
|
|
@@ -70,7 +156,7 @@ Report the contract, the tokens used, what you watched (filmstrip paths, cases e
|
|
|
70
156
|
| Second input mid-transition | Retarget or reverse from the current visual position; latest desired state wins. |
|
|
71
157
|
| Unmount or navigation | Frames, timers, listeners, observers and renderer resources released. |
|
|
72
158
|
| Resize or content change | Geometry recomputed; unrelated motion not restarted. |
|
|
73
|
-
| Reduced motion |
|
|
159
|
+
| Reduced motion | Movement removed; the end state shown with focus and content intact; no reliance on end events. |
|
|
74
160
|
| Hidden tab or offscreen | Continuous work paused; resumes without a jump. |
|
|
75
161
|
| Async failure | Usable state restored with the error; no decorative success left on screen. |
|
|
76
162
|
|
|
@@ -86,10 +172,12 @@ All captions contribute height; only the active one shows. Inactive ones are hid
|
|
|
86
172
|
|
|
87
173
|
### Measured indicators
|
|
88
174
|
|
|
89
|
-
Sliding tab underlines and selection pills read their target's `offsetLeft/Top/Width/Height` and recompute on resize and when the tab row wraps. Animate `translate` and `
|
|
175
|
+
Sliding tab underlines and selection pills read their target's `offsetLeft/Top/Width/Height` and recompute on resize and when the tab row wraps. Animate `translate` and `scale`, not `left` or `width`. For a colour change that has to be exact across the pill, duplicate the tab row styled as active and animate a `clip-path: inset()` over it.
|
|
90
176
|
|
|
91
177
|
### Reduced motion wiring
|
|
92
178
|
|
|
179
|
+
Reduced motion means less movement, not a dead interface. Remove travel, scale and parallax; keep short opacity and colour changes that help comprehension, and keep immediate feedback such as focus.
|
|
180
|
+
|
|
93
181
|
```css
|
|
94
182
|
@media (prefers-reduced-motion: reduce) {
|
|
95
183
|
*, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important;
|
|
@@ -97,15 +185,11 @@ Sliding tab underlines and selection pills read their target's `offsetLeft/Top/W
|
|
|
97
185
|
}
|
|
98
186
|
```
|
|
99
187
|
|
|
100
|
-
|
|
188
|
+
That blanket rule is the floor for CSS. Components that keep a fade opt back in deliberately. JS and canvas timelines check `matchMedia("(prefers-reduced-motion: reduce)")` and jump to the end state (or a still frame that reads whole).
|
|
101
189
|
|
|
102
190
|
### Scroll-linked vs scroll-triggered
|
|
103
191
|
|
|
104
|
-
Scroll-linked (progress maps to scroll position) must behave when scrolling backwards and when the page restores mid-way. Scroll-triggered entrances run once and must leave the content visible if JS fails or the observer never fires: default CSS shows the final state; the animation is an enhancement.
|
|
105
|
-
|
|
106
|
-
### Performance
|
|
107
|
-
|
|
108
|
-
Animate `transform` and `opacity` by default. Large filters, blurs, shadows and canvas at device pixel ratio cost real frames on phones. When something hitches, record a performance trace in the browser before rewriting; report frame rates only when measured, with device and conditions.
|
|
192
|
+
Scroll-linked (progress maps to scroll position) must behave when scrolling backwards and when the page restores mid-way. Scroll-triggered entrances run once and must leave the content visible if JS fails or the observer never fires: default CSS shows the final state; the animation is an enhancement. `clip-path: inset(0 0 100% 0)` to `inset(0)` is a cheap, compositor-friendly reveal.
|
|
109
193
|
|
|
110
194
|
### Verification matrix
|
|
111
195
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Worst-case data
|
|
2
|
+
|
|
3
|
+
Demo data flatters a design: names that fit on one line, counts that never need a separator, every optional field filled. Before calling a screen done, feed it realistic worst cases through the same boundary the real data uses (the fixture, the mock, the props or the API stub), never by hand-editing markup. Adapted from [Emil Kowalski's break-ui skill](https://github.com/emilkowalski/skills) (MIT).
|
|
4
|
+
|
|
5
|
+
## Method
|
|
6
|
+
|
|
7
|
+
1. **List every value the screen renders** with its source and its limit: the validation schema, the database column, the API type, the input's `maxLength`. "Unbounded" is a finding. Include the values people forget: counts in headers, relative times, badge and status text, labels that come from data, avatars, and the length of each list.
|
|
8
|
+
2. **Build one worst-case fixture** shaped like the demo data, spreading different failures across the first rows rather than stacking them in row one. Add **empty**, **one** and **huge** variants (zero items, a count of exactly 1, the realistic upper bound for an unpaginated list).
|
|
9
|
+
3. **Make it switchable in development only**: a `?data=worst` parameter read where the fixture is chosen, or a prototype route. It never ships.
|
|
10
|
+
4. **Look**: at the component's real container width, at 320px and at the widest layout, with text enlarged to 200%, in dark mode, and under `dir="rtl"` if the product supports it. `shoot.mjs` at those widths plus a read of each capture.
|
|
11
|
+
5. **Report** each break with its fix, then fix what is clearly a defect. Truncate-or-wrap and hide-or-dash are design decisions; name them rather than guessing silently.
|
|
12
|
+
|
|
13
|
+
Plausible values only. A 5,000-character name or `aaaaaaaa…` proves nothing and gets ignored. Use `example.com` and `.test` domains in fixtures.
|
|
14
|
+
|
|
15
|
+
## Values that break things
|
|
16
|
+
|
|
17
|
+
| Kind | Try | What tends to break |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| Names | `Aleksandra Wiśniewska-Kowalczyk`, `Jo`, `J`, `王秀英`, `نور الهدى عبد الرحمن`, `María José de la Cruz y Fernández`, `🦊 Fox`, `👩🏽💻 Priya`, missing | Wrapping, initials logic (half an emoji from `.charAt(0)`, `Md` initials), RTL punctuation, empty fallback |
|
|
20
|
+
| Unbreakable strings | `bartholomew.fitzgerald@northwind-industries-holdings.example.com`, a long URL with a query, a UUID, `Q3 Board Deck — FINAL (revised) v12 [approved by legal].pdf` | Rows pushed wide without `overflow-wrap: anywhere` and `min-width: 0`; end-truncation hiding the part that differs |
|
|
21
|
+
| Labels from data | `Senior Product Design Engineer, Platform Infrastructure`, `Benachrichtigungseinstellungen`, French or German copy (~30% longer), twelve tags, one 40-character tag, empty title, `<script>alert(1)</script>`, a newline in a one-line field | Badges squeezing names, tag walls with no `+8` overflow, collapsed headings, escaping |
|
|
22
|
+
| Numbers | `0`, `1`, `1284`, `1000000`, `12345678.9` as money, `-42.5`, `0.1 + 0.2`, `142%`, `null`, a value ticking `99` → `100` | Division by zero, "1 members", missing separators, raw float noise, meters past their bounds, `NaN` on screen, width jitter without `tabular-nums` |
|
|
23
|
+
| Collections | 0, 1, exactly a page, a page plus one, 1,000+, one item ten times taller, duplicate names | Missing empty state, a lonely single card, empty page 2, scroll and render cost, rows stretched to the tallest |
|
|
24
|
+
| Time | now, 12 days ago, 3 years ago, a future date, `1970-01-01`, a late-evening time in another timezone | "0 seconds ago", relative time that never becomes a date, "-3 days ago", a zero timestamp shown as real, the wrong day. Use `Intl.RelativeTimeFormat` and `Intl.DateTimeFormat`. |
|
|
25
|
+
| Media | a 404 avatar, no avatar, a 4000×200 image, a dark transparent logo in dark mode, a slow image | Broken-image icons, squashed avatars without `object-fit` and `flex-shrink: 0`, invisible logos, layout shift without `aspect-ratio` |
|
|
26
|
+
| States | loading, an API error, partial optional fields, every status value at once, no permission, the current user in the list | Skeletons that do not match, raw `TypeError` text, misaligned rows, uneven badge widths, actions that should not apply to yourself |
|
|
27
|
+
|
|
28
|
+
## Failure signatures
|
|
29
|
+
|
|
30
|
+
| What you see | Usual cause | Fix |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| Avatar or icon squashed into an oval | Flex child shrinking | `flex-shrink: 0` on fixed-size boxes |
|
|
33
|
+
| A row pushed wider than its container | Long unbreakable text in a flex or grid child | `min-width: 0` on the child, `overflow-wrap: anywhere` or truncation on the text |
|
|
34
|
+
| Text clipped mid-glyph | Fixed height with `overflow: hidden`, tight line height | Let the height grow, or clamp lines with `-webkit-line-clamp` |
|
|
35
|
+
| Trailing actions pushed off screen | The name column will not give way | Let the name truncate; keep actions `flex-shrink: 0` |
|
|
36
|
+
| "1 members", "0 results found" | String concatenation | `Intl.PluralRules` or the i18n library's plurals |
|
|
37
|
+
| Hover-only actions unreachable on touch | `opacity: 0` until `:hover` | Show them on touch (`@media (hover: none)`) or move them into a menu |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: web-graphics
|
|
3
|
-
description: Make bespoke graphics for the web, such as isometric scenes, flat illustrations, ASCII and halftone art, diagrams, icons, mascots, social cards and hero images, as editable code-built assets that hold up in the actual page. Use when asked for an illustration, a hero graphic, an explainer visual, an icon set, an OG image, "make a graphic for", or when a page needs visual identity beyond layout.
|
|
3
|
+
description: Make bespoke graphics for the web, such as isometric scenes, animated sticker icons, flat illustrations, ASCII and halftone art, diagrams, icons, mascots, social cards and hero images, as editable code-built assets that hold up in the actual page. Use when asked for an illustration, a hero graphic, an explainer visual, an icon set, animated or Lottie-style icons, onboarding or empty-state art that moves, an OG image, "make a graphic for", or when a page needs visual identity beyond layout.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Web graphics
|
|
@@ -34,6 +34,7 @@ A set of graphics shares this grammar. Variants change a few meaningful objects
|
|
|
34
34
|
|
|
35
35
|
Read the relevant section of [techniques](references/techniques.md):
|
|
36
36
|
|
|
37
|
+
- **Animated stickers** (playful slab icons that move: onboarding, empty states, rewards, feature icons): follow [stickers](references/stickers.md). Draw on [the bundled kit](kit/fun.ts), animate only with its keyframe presets, and check with [`scripts/fi.mjs`](scripts/fi.mjs) (`check`, `sheet`, `frame`, `export` to alpha video). It needs no install beyond Chrome and ffmpeg.
|
|
37
38
|
- **Isometric**: write the scene as JSON and compile with [`scripts/iso.mjs`](scripts/iso.mjs) (`node <skill-dir>/scripts/iso.mjs --example` shows the format). It handles projection, face shading and draw order, which is where hand-written isometric SVG breaks.
|
|
38
39
|
- **Flat and icons**: a 24-unit grid, shared corner radius and stroke; check silhouettes at 16px.
|
|
39
40
|
- **ASCII and halftone**: deterministic generation, never `Math.random()` during render.
|
|
@@ -56,6 +57,7 @@ Look at each capture at the smallest real size first. Check: does the focal poin
|
|
|
56
57
|
- Inline SVG gets `role="img"` and an `aria-label` or `<title>` when it carries meaning, `aria-hidden="true"` when decorative.
|
|
57
58
|
- Responsive: `viewBox` without fixed width/height in CSS contexts; `width: 100%; height: auto`.
|
|
58
59
|
- Size: SVG under ~30 KB unless it is the hero; strip editor metadata.
|
|
60
|
+
- Anything that moves has a still: reduced motion shows a frame where the graphic reads whole, and an exported video ships with its poster.
|
|
59
61
|
- Report the grammar, files, sizes inspected and anything unverified.
|
|
60
62
|
|
|
61
63
|
## Refuse
|
|
@@ -65,3 +67,4 @@ Look at each capture at the smallest real size first. Check: does the focal poin
|
|
|
65
67
|
- Fake dashboards, invented numbers or real-looking personal data inside illustrations.
|
|
66
68
|
- Texture or low-contrast art behind legal, pricing or operational text.
|
|
67
69
|
- Prompt text delivered as if it were the asset.
|
|
70
|
+
- Hand-written sine wobbles or CSS keyframe jiggles on illustrations. Motion comes from a preset or a spring with a reason.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kelvin Adithya (fun-isometric, https://skills.klvn.dev/fun-isometric)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// fun-isometric in React: one canvas sized by css, on the shared page clock, recoloured and replayed without remounting.
|
|
2
|
+
import { useEffect, useRef } from 'react'
|
|
3
|
+
import { play } from './fun'
|
|
4
|
+
import type { CSSProperties } from 'react'
|
|
5
|
+
import type { Icon, PlayOptions, Player } from './fun'
|
|
6
|
+
|
|
7
|
+
type Props = PlayOptions & {
|
|
8
|
+
icon: Icon
|
|
9
|
+
replayOnHover?: boolean
|
|
10
|
+
label?: string // accessible name, the icon's name by default
|
|
11
|
+
className?: string
|
|
12
|
+
style?: CSSProperties
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Sticker({ icon, palette, loop, offset, enter, replayOnHover, label, className, style }: Props) {
|
|
16
|
+
const ref = useRef<HTMLCanvasElement>(null)
|
|
17
|
+
const player = useRef<Player | null>(null)
|
|
18
|
+
const opts = useRef<PlayOptions>({ palette, loop, offset, enter })
|
|
19
|
+
opts.current = { palette, loop, offset, enter }
|
|
20
|
+
// a new icon starts a new player; options reach the running one through set(), so the entrance never replays
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!ref.current) return
|
|
23
|
+
const p = play(ref.current, icon, opts.current)
|
|
24
|
+
player.current = p
|
|
25
|
+
return () => p.stop()
|
|
26
|
+
}, [icon])
|
|
27
|
+
// keyed on the palette's content, so an inline object does not repaint every render
|
|
28
|
+
const tone = JSON.stringify(palette ?? null)
|
|
29
|
+
useEffect(() => player.current?.set(opts.current), [tone, loop, offset])
|
|
30
|
+
return (
|
|
31
|
+
<canvas
|
|
32
|
+
ref={ref}
|
|
33
|
+
role="img"
|
|
34
|
+
aria-label={label ?? icon.name}
|
|
35
|
+
className={className}
|
|
36
|
+
style={{ display: 'block', width: '100%', aspectRatio: '1', ...style }}
|
|
37
|
+
onPointerEnter={replayOnHover ? () => player.current?.replay() : undefined}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
}
|