Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ The system is **two-tier**, so theming is a remap of one layer and never touches

Alongside colour: **typography** (Figtree sans + Geist Mono, type scale `text-xs…6xl`, weights,
leading, tracking), **spacing** (4px base), **radii**, **elevation** (`shadow-*`), and **motion**
(durations + easings). Token names flatten onto Tailwind v4 `@theme` namespaces so utilities
(durations `micro…slower`, easings incl. `ease-spring`, and ready-made `animate-*` presets -
`pop-in/out`, `shake`, `fade-in/out` - each composing the tokens and gated by consumers with
`motion-reduce:animate-none`; gating is **mandatory** for `shake`, a vestibular trigger). Token
names flatten onto Tailwind v4 `@theme` namespaces so utilities
generate directly: `color-*`→`bg-*`/`text-*`, `radius-md`→`rounded-md`, `text-lg`→`text-lg`,
`font-sans`→`font-sans`, `shadow-md`→`shadow-md`, and spacing utilities (`p-4`, `gap-2`) derive
from a single `--spacing` base.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 0015 - Tests must pin every shipped variant and every acceptance property

## Symptom

Spec 0033 shipped five new `animate-*` presets (`pop-in/out`, `shake`, `fade-in/out`) but the
first test pass only asserted the `-in` halves and `shake`, leaving `pop-out` / `fade-out`
unverified - a regression dropping either would have stayed green. Separately, the spec's own
Acceptance listed "the fold stays idempotent (no double-append)" as a deliverable, yet no test
enforced it: the idempotency lived only in `build.mjs`'s prose, so a regression to the old
append-in-place bug (feedback 0003) would have doubled every keyframe while every `toContain`
assertion still passed. Two personas (tester, engineer) independently flagged both gaps on PR #45.

## Root cause

The tests asserted the *representative* case, not the *contract*. When a change ships a set of
variants, testing one member reads as "covered" but only pins that member. And an acceptance
bullet that names a property (idempotency) is a promise; if no test encodes it, the promise is
enforced by comment, which rots silently - the exact failure mode of a `toContain` assertion that
can't see a duplicate.

## Fix

- Assert BOTH halves of every preset pair and that each `--animate-*` value *composes* its tokens
(`var(--duration-*)` / `var(--ease-*)`), not merely that it is present - looping over the full
variant list rather than hand-picking one.
- Add an occurrence-count guard (`split(needle).length - 1 === 1`) for a keyframe and an
`--animate-*` in the folded preset, turning the idempotency acceptance item into a real test.

## Learning

**Test at the granularity the spec promises.** For a set of shipped variants, assert *every*
member, not a representative one - a per-variant loop beats a hand-picked sample. For any property
an acceptance item names (idempotency, ordering, single-occurrence), write the test that *fails*
if it regresses; a `toContain` that passes on both the correct and the doubled output is not
coverage. This is the second coverage-gap review in a row (see
[`0014-interactive-component-test-coverage.md`](0014-interactive-component-test-coverage.md)):
the recurring root is testing the happy representative instead of the whole contract.
10 changes: 6 additions & 4 deletions docs/overview/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ the README; Canopy is the whole system.
type-safe access. tsup then compiles it in place to `tokens.js` + `tokens.d.ts`.
3. `tailwind-preset.css` - a Tailwind v4 `@theme inline { … }` block (custom SD format
`tailwind/preset-v4`) so utilities like `bg-primary` resolve. CSS-first Tailwind v4 means
tokens live in CSS, not a JS config object. It **also ships the design system's overlay-motion**
- `@keyframes` + a `@theme` block of `--animate-dialog-*` vars (composing the `--duration-*` /
`--ease-*` motion tokens) that generate `animate-dialog-*` utilities - folded in from the
tokens live in CSS, not a JS config object. It **also ships the design system's keyframed motion**
- `@keyframes` + a `@theme` block of `--animate-*` vars (composing the `--duration-*` /
`--ease-*` motion tokens) that generate `animate-*` utilities - both the overlay/drawer/sheet
motion (`animate-dialog-*`, `-drawer-*`, `-bottom-sheet-*`) and the generic expressive presets
(`animate-pop-*`, `-shake`, `-fade-*`, spec 0033) - folded in from the
hand-authored `preset-motion.css` partial by `build.mjs` (an idempotent single write, like the
`tokens.css` fold). Keyframes / `@theme --animate-*` are theme declarations, not utilities, so
`@source` could never emit them; shipping them from the preset every consumer imports is what
Expand All @@ -60,7 +62,7 @@ DTCG sources under `packages/roots/tokens/`, split by concern:
| `space.json` | `space.0…32` (4px base) |
| `radius.json` | `radius.none…full` |
| `shadow.json` | `shadow.sm…xl` |
| `motion.json` | `duration.fast/base/slow`, `ease.standard/emphasized/decelerate` |
| `motion.json` | `duration.micro/fast/base/slow/slower`, `ease.standard/emphasized/decelerate/spring/spring-strong` |
Comment thread
mattmaynes marked this conversation as resolved.

### Tailwind v4 namespace mapping

Expand Down
5 changes: 4 additions & 1 deletion docs/overview/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ theme only; the dark remap + runtime switching are 0004.
`text-h2` applies font-size + line-height + font-weight + letter-spacing in one class.
Components style against these, not raw scale + weight + leading.
- **Spacing / radii / elevation / motion** - `space.0…32` (4px base); `radius.none…full`;
`shadow.sm…xl` (soft, warm); `duration.fast/base/slow` + `ease.standard/emphasized/decelerate`.
`shadow.sm…xl` (soft, warm); `duration.micro/fast/base/slow/slower` +
`ease.standard/emphasized/decelerate/spring/spring-strong`. The Roots preset also ships
token-composed `animate-*` presets - `pop-in/out` (spring), `shake` (error states),
`fade-in/out` - alongside the overlay/drawer/bottom-sheet motion (spec 0033).
- **Self-hosted fonts** - Figtree + Geist Mono via `@fontsource-variable/*`, imported in
Storybook's global CSS. Roots ships only the family tokens; consumers install @fontsource.
- **Tailwind v4 utilities** - token names flatten onto `@theme` namespaces so utilities
Expand Down
16 changes: 16 additions & 0 deletions docs/overview/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,3 +505,19 @@ drive it by **keyboard** in at least one test (not just `click`), and if it supp
and uncontrolled use, test **both** (controlled: the display follows the `value` prop, and
`onValueChange` fires without the display changing until the parent updates). Don't let an
acceptance item be satisfied by the easiest interaction that superficially passes.

## Test every shipped variant and every acceptance property, not a representative sample

Spec 0033 shipped five `animate-*` presets but first tested only the `-in` halves, and listed
"the fold stays idempotent" in Acceptance with no test enforcing it - a dropped `-out` preset or a
regressed double-append would both have stayed green, because a `toContain` assertion passes on the
correct AND the doubled/partial output. Caught by the tester + engineer personas. See
[`docs/feedback/0015-assert-every-shipped-variant-and-acceptance-property.md`](../feedback/0015-assert-every-shipped-variant-and-acceptance-property.md).

**Apply it:** when a change ships a set of variants (preset pairs, token families), assert **every**
member with a per-variant loop, not a hand-picked one - and check composition/value, not mere
presence. When an acceptance item names a property (idempotency, single-occurrence, ordering),
encode the test that **fails** if it regresses (e.g. an occurrence-count guard), because a
presence check can't see a duplicate. This recurs with
[keyboard/controlled coverage (0014)](../feedback/0014-interactive-component-test-coverage.md): the
shared root is testing the happy representative instead of the whole contract.
119 changes: 119 additions & 0 deletions docs/specs/0033-expressive-motion-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 0033 - Expressive motion tokens (spring easings + motion presets)

## Problem

Canopy's motion scale is deliberately small but currently one-note: three durations
(`fast/base/slow`) and three easings (`standard/emphasized/decelerate`) that all **decelerate**.
There is no overshoot/spring curve and no ready-made motion for the small, expressive
interactions components keep re-inventing inline - a badge popping in, a form field shaking on
an invalid submit, a toast fading up. Consumers either hardcode `cubic-bezier`/`ms` values (off
the token seam) or skip the motion entirely.

transitions.dev (https://github.com/Jakubantalik/transitions.dev) is a well-tuned catalogue of
exactly these micro-interactions. We want its best, broadly-reusable motion **as Canopy tokens +
preset utilities**, curated to fit Canopy's minimal, opinionated scale - not a wholesale import.

Who it is for: component authors (Branches) and app consumers who want tasteful spring/pop/shake
motion that already composes Canopy's `--duration-*` / `--ease-*` tokens, works out of the box
from the preset every consumer imports, and honours `motion-reduce`.

## Outcome

- **Two new easing tokens** in `tokens/motion.json` (adapted from transitions.dev's bounce pair):
- `ease.spring` = `cubic-bezier(0.34, 1.36, 0.64, 1)` - gentle overshoot for playful entrances.
- `ease.spring-strong` = `cubic-bezier(0.34, 2.2, 0.64, 1)` - pronounced bounce, celebratory
accents, use sparingly.
- **Two new duration tokens** extending the scale at both ends (existing `fast/base/slow`
unchanged, so no collision and no consumer breakage):
- `duration.micro` = `80ms` - tiny state changes, staggered children, badge/icon pops.
- `duration.slower` = `480ms` - expressive / spring-driven motion that needs room to settle.
- **Three motion presets** hand-authored in `preset-motion.css` as `@keyframes` + a `@theme`
block of `--animate-*` vars that **compose the tokens** (never hardcoded ms/easing), matching
the existing `animate-dialog-*` / `animate-drawer-*` / `animate-bottom-sheet-*` pattern exactly:
- `animate-pop-in` / `animate-pop-out` - scale + fade; `-in` springs, `-out` settles.
- `animate-shake` - short horizontal shake for error/invalid states.
- `animate-fade-in` / `animate-fade-out` - pure opacity for toasts/content reveal.
- All five outputs stay in lockstep: the new tokens appear in `dist/tokens.css` (`:root` vars),
`dist/tokens.ts` (typed export), and `dist/tailwind-preset.css` (`@theme inline`), and the new
`animate-*` utilities ship folded into `dist/tailwind-preset.css` from `preset-motion.css`.
- `pnpm test` + `pnpm lint` + `pnpm build` stay green in `packages/roots`; README motion section
and `docs/overview/` (`features`, `architecture`) updated to match reality.

## Scope

In:

- `tokens/motion.json` - add `duration.micro`, `duration.slower`, `ease.spring`,
`ease.spring-strong` with `$description`s.
- `preset-motion.css` - add the `pop-*` / `shake` / `fade-*` keyframes and their token-composed
`--animate-*` theme vars; extend the file's header comment to cover the new presets.
- `tokens.test.ts` - assert the new tokens land in each output and the new `animate-*` utilities
ship token-composed (grep the built preset, per the existing motion test).
- README motion section + `docs/overview/features.md` / `architecture.md`.

Out:

- `success-check` and other **multi-element composites** from transitions.dev (fade+rotate+blur+
bob+SVG-path-draw). A single `--animate-*` utility is one keyframe timeline on one element; the
check's path-draw needs `stroke-dashoffset` on a nested SVG path. That is component motion, not
a token - a future Branches component can compose these tokens for it. Noted as future work.
- Distance / scale / blur helper tokens from transitions.dev. Canopy authors those inline per
component today; adding a parallel token family is a separate decision, not this spec.
- Changing or renaming any existing token (`fast/base/slow`, `standard/emphasized/decelerate`)
or existing `animate-dialog-*` / `-drawer-*` / `-bottom-sheet-*` utility. Purely additive.
- Retrofitting existing components to use the new presets (each is its own small follow-up).

## Approach

**Tokens.** Append to the existing DTCG groups in `motion.json`. Durations keep file order
low-to-high (`micro`, `fast`, `base`, `slow`, `slower`); easings append after `decelerate`. The
Style Dictionary pipeline already runs `cubicBezier/css` + duration transforms, so no config or
transform change is needed - the new tokens flow through the same three web outputs automatically.

**Naming.** Canopy easings are named by **intent** (`standard`/`emphasized`/`decelerate`), so
transitions.dev's `bounce` becomes `spring` (subtle) / `spring-strong` (pronounced). Durations
keep Canopy's t-shirt scale; transitions.dev's own `fast`(250)/`slow`(400) values are **not**
reused - they would collide with Canopy's `fast`(120)/`slow`(320). We map intent, not literals.

**Presets.** Mirror the established partial exactly - a pair of `@keyframes` (`-in`/`-out`) plus
`@theme { --animate-<name>: <keyframes> var(--duration-*) var(--ease-*); }`. Choices:

- `pop-in`: `scale(0.9)`+`opacity:0` -> `scale(1)`+`opacity:1`, `var(--duration-base)`
`var(--ease-spring)` - the flagship showcase of the new spring curve (badges, popovers, menus,
tooltips).
- `pop-out`: reverse to `scale(0.96)`+`opacity:0`, `var(--duration-fast)` `var(--ease-standard)` -
exits settle rather than bounce, matching Canopy's existing `*-out` convention (dialog/drawer
exits use `--ease-standard`).
- `shake`: 3 short `translateX` cycles (`+/- 4px`), `var(--duration-slow)` `var(--ease-standard)` -
for `aria-invalid` form fields; the amplitude stays small and finite so it reads as feedback,
not decoration.
- `fade-in` / `fade-out`: pure `opacity`, `var(--duration-base)`/`var(--duration-fast)`
`var(--ease-standard)` - the most reusable primitive (toasts, content).

Every consumer gates these with `motion-reduce:animate-none` at the call site (documented in the
README), same as the dialog/drawer motion - the preset ships the motion; the component opts in.

**Testing.** Follow the existing motion test: build the preset in-test and grep the **built rule**
(the literal-class learning - keyframes/`@theme --animate-*` can never come from `@source`), and
assert each new `--animate-*` value contains the expected `var(--duration-*)`/`var(--ease-*)` so
motion stays token-driven, not hardcoded. Assert the four new tokens appear in `tokens.css`,
`tokens.ts`, and the preset's `@theme inline`.

## Acceptance

- [ ] `motion.json` defines `duration.micro` (80ms), `duration.slower` (480ms), `ease.spring`
(`cubic-bezier(0.34, 1.36, 0.64, 1)`), `ease.spring-strong`
(`cubic-bezier(0.34, 2.2, 0.64, 1)`) with `$description`s; existing tokens unchanged.
- [ ] `pnpm build` emits the four new tokens into `dist/tokens.css`, `dist/tokens.ts`, and
`dist/tailwind-preset.css` (`@theme inline`).
- [ ] `preset-motion.css` adds `@keyframes` for pop-in/out, shake, fade-in/out and `--animate-*`
theme vars that COMPOSE `--duration-*` / `--ease-*` (no hardcoded ms/easing); they ship
folded into `dist/tailwind-preset.css`, and existing `animate-dialog-*`/`-drawer-*`/
`-bottom-sheet-*` utilities are untouched.
- [ ] `tokens.test.ts` asserts: the four new tokens in each output; `animate-pop-in` composes
`var(--duration-base)` + `var(--ease-spring)`; `animate-shake` and `animate-fade-in` ship
token-composed; the fold stays idempotent (no double-append).
- [ ] `pnpm test` + `pnpm lint` green in `packages/roots`.
- [ ] README motion section documents the new easings, durations, and `animate-*` utilities
(with the `motion-reduce` gating note); `docs/overview/features.md` + `architecture.md`
updated to match. `success-check` listed as future component-level work.
69 changes: 67 additions & 2 deletions packages/roots/preset-motion.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Roots overlay-motion partial (the design system's keyframed motion).
* Roots motion partial - the design system's keyframed motion (overlay + expressive).
*
* `@keyframes` and `@theme --animate-*` are THEME declarations, not utilities, so Tailwind's
* `@source` scanner can NEVER emit them - they must ship from CSS every consumer already imports.
Expand All @@ -15,7 +15,17 @@
* (`animate-drawer-in` / `-out`), first consumed by SideNav (spec 0026): the mobile drawer slides in
* from the left edge and back out, gated the same way. It further carries the bottom-sheet slide
* (`animate-bottom-sheet-in` / `-out`), first consumed by ResponsiveDialog (spec 0031): the mobile
* dialog form slides up from the bottom edge and back down, gated the same way.
* dialog form slides up from the bottom edge and back down, gated the same way. The partial now
* ALSO carries generic expressive presets, not tied to one component: `animate-pop-in` / `-out`
* (spring scale + fade for badges, popovers, menus), `animate-shake` (a short horizontal shake for
* error / invalid states), and `animate-fade-in` / `-out` (pure opacity for toasts and content
* reveal). Each composes the `--duration-*` / `--ease-*` tokens and is gated by consumers with
* `motion-reduce:animate-none`, the same as the motion above.
*
* REDUCED MOTION IS MANDATORY, not optional, for `animate-shake`: a repeated translate is a
* vestibular trigger, so any consumer applying it to an error / `aria-invalid` state MUST pair it
* with `motion-reduce:animate-none`. The overlay/pop/fade presets are also gated by convention,
* but shake is the one that can actively harm - never ship it ungated.
*/
@keyframes dialog-overlay-in {
from {
Expand Down Expand Up @@ -85,6 +95,56 @@
transform: translateY(100%);
}
}
@keyframes pop-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pop-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}
@keyframes shake {
Comment thread
mattmaynes marked this conversation as resolved.
0%,
100% {
transform: translateX(0);
}
20%,
60% {
transform: translateX(-4px);
}
40%,
80% {
transform: translateX(4px);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

@theme {
--animate-dialog-overlay-in: dialog-overlay-in var(--duration-slow) var(--ease-decelerate);
Expand All @@ -95,4 +155,9 @@
--animate-drawer-out: drawer-out var(--duration-base) var(--ease-standard);
--animate-bottom-sheet-in: bottom-sheet-in var(--duration-slow) var(--ease-decelerate);
--animate-bottom-sheet-out: bottom-sheet-out var(--duration-base) var(--ease-standard);
--animate-pop-in: pop-in var(--duration-base) var(--ease-spring);
--animate-pop-out: pop-out var(--duration-fast) var(--ease-standard);
--animate-shake: shake var(--duration-slow) var(--ease-standard);
Comment thread
mattmaynes marked this conversation as resolved.
--animate-fade-in: fade-in var(--duration-base) var(--ease-standard);
--animate-fade-out: fade-out var(--duration-fast) var(--ease-standard);
}
Loading
Loading