Skip to content

feat(ui): elegant, composable motion system — vocabulary, <Presence>, CI gate (0199) - #171

Merged
crs48 merged 10 commits into
mainfrom
feat/motion-system
Jun 18, 2026
Merged

feat(ui): elegant, composable motion system — vocabulary, <Presence>, CI gate (0199)#171
crs48 merged 10 commits into
mainfrom
feat/motion-system

Conversation

@crs48

@crs48 crs48 commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Summary

Implements exploration 0199 — Elegant, Composable Motion System. The repo already had a strong CSS-first motion foundation; this hardens it into a single, enforced, AI-legible motion vocabulary and extends the elegant Base UI enter/exit pattern to plain React mount/unmount — 0 KB added on the default path, no animation library.

The same restraint that makes the UI feel designed makes motion easy for an agent to author consistently. So the deliverable is a small frozen vocabulary + a checker that turns "wrong animation" into a build error the author self-corrects.

What's in it

  • Frozen vocabulary (packages/ui/src/theme/motion.css): documented header (the two laws — enter slower+ease-out, exit faster+ease-in; the allowed durations/easings/primitives). Retired --ease-bounce (no consumers; dropped from the Tailwind config too) and added a pop-in primitive.
  • <Presence> (packages/ui/src/motion/Presence.tsx): extends the Base UI data-ending-style trick to any React child — keeps the node mounted through its exit keyframe, unmounts on animationend. No JS animation library.
  • useViewTransition(): wraps the native View Transitions API with reduced-motion + feature-detect fallbacks for discrete surface/list swaps.
  • .stagger utility for list entrances (single --i delay var).
  • docs/MOTION.md: the one-page style guide that doubles as the AI system prompt.
  • scripts/check-motion-vocab.mjs wired into the CI lint job: bans transition-all, raw duration-<ms> literals, ease-bounce, and arbitrary animate-[…] across packages/ui/src + apps/web/src (the token-bearing scope). Follows the repo's existing check-*.mjs pattern rather than a custom ESLint plugin.
  • Codemod: the 18 transition-all + 5 raw-duration sites in scope → explicit, compositor-only forms (transition-[opacity,transform], transition-[width], transition-transform, duration tokens). Gate is green across 379 files.
  • Applied: UndoToast (slide-up, with message-latching so text survives the exit) and StorageWarningBanner (slide-down) now animate in and out; TabBreadcrumb space re-scope cross-fades via useViewTransition(); the editor's menu-disappear corrected to ease-in (the exit law).

Deviations (documented in the exploration)

  • Checker, not an ESLint rule — the repo has no custom-ESLint-plugin infra but a well-worn check-*.mjs CI pattern; a text scanner is also more reliable for Tailwind class strings. Same outcome.
  • Enforcement scope = packages/ui + apps/web — only these build with the token-bearing config. The editor's --editor-* theme is intentionally out of scope (where duration-200 is a correct Tailwind default).
  • EditorArea/TabBar route-level transitions deferred — both are router-authoritative and exercised by the flake-sensitive editor-ux e2e; the reusable helper ships and is proven on the contained breadcrumb re-scope.

Testing

  • 11 new unit tests for <Presence> + useViewTransition (green).
  • @xnetjs/ui and xnet-web typecheck clean against built deps.
  • pnpm check:motion-vocab: 0 violations / 379 files; verified it exits non-zero on each planted footgun.
  • Booted the worktree dev server: full workbench renders, <Presence>-wrapped StorageWarningBanner live, no console errors, ease-bounce gone without breakage.
  • vitest --changed=origin/main: 724/726; the 2 failures are pre-existing perf-budget flakes (markdown-import timing, 10k-row grid cursor) that pass in isolation and get higher budgets on CI — unrelated to motion.

Changelog

Motion: a consistent, minimal animation vocabulary — toasts and banners now glide in and out, list re-scopes cross-fade, and a CI gate keeps motion clean and on-style across the app.

🤖 Generated with Claude Code

xNet Test and others added 9 commits June 17, 2026 16:21
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…retire ease-bounce

The canonical motion system gains a documented vocabulary header (the two
laws + the allowed durations/easings/primitives), a <Presence> enter/exit
keyframe layer (.motion-presence[data-state][data-motion]), a .stagger
list-entrance utility, and a pop-in primitive. --ease-bounce is retired
(no consumers; its negative anticipation is the opposite of minimal) and
dropped from the Tailwind config.

Refs docs/explorations/0198.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
<Presence> extends the Base UI data-ending-style enter/exit trick to any
React child (keep mounted through exit, unmount on animationend) — no JS
animation library. useViewTransition() wraps the native View Transitions
API with reduced-motion + feature-detect fallbacks for discrete surface/
list swaps. Both exported from @xnetjs/ui; 11 unit tests green.

Refs docs/explorations/0198.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t transition-all

Adds docs/MOTION.md (the one-page vocabulary that doubles as the AI prompt)
and scripts/check-motion-vocab.mjs, wired into the CI lint job. The gate
bans transition-all, raw duration-<ms> literals, ease-bounce, and arbitrary
animate-[…] across packages/ui/src + apps/web/src (the token-bearing scope).

Codemods the 18 transition-all + 3 raw-duration sites in scope to explicit,
compositor-only forms (transition-[opacity,transform], transition-[width],
duration tokens) so the gate is green. 370 files scanned, 0 violations.

Refs docs/explorations/0198.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…umb; de-drift editor

- UndoToast: enter+exit via <Presence motion=slide-up> (previously popped in
  with no exit); message latched so it survives the exit; centered with
  auto-margins so the translateY keyframe doesn't fight a static transform.
- StorageWarningBanner: <Presence motion=slide-down> enter/exit.
- TabBreadcrumb: space re-scope cross-fades via useViewTransition().
- editor: menu-disappear now uses ease-in (the exit law) instead of ease-out,
  with a comment aligning the local set to docs/MOTION.md.

Refs docs/explorations/0198.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… on main)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on-normal)

The Linear-grade tasks UI merged from main predated the motion gate; two
raw duration-150 literals are exact no-op renames to the duration-normal
token. Keeps check:motion-vocab green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@crs48
crs48 temporarily deployed to pr-171 June 17, 2026 23:54 — with GitHub Actions Inactive
@crs48
crs48 temporarily deployed to pr-171 June 17, 2026 23:56 — with GitHub Actions Inactive
@github-actions

Copy link
Copy Markdown
Contributor

🖼️ UI changes in this PR

Screens

✏️ Home _(SSIM 0.939)_
before after diff
before after diff
✏️ Social import _(SSIM 0.809)_
before after diff
before after diff

Interactions

🎬 Create a page and use the editor

Create a page and use the editor

▶ Watch MP4

Auto-captured by CI · run. Informational — not a blocking check.

github-actions Bot added a commit that referenced this pull request Jun 18, 2026
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Preview removed for PR #171.

github-actions Bot added a commit that referenced this pull request Jun 18, 2026
]

/** Recursively collect .ts/.tsx files under a directory. */
function collect(dir, out) {
@crs48
crs48 merged commit 10a74a6 into main Jun 18, 2026
13 of 14 checks passed
@crs48
crs48 deleted the feat/motion-system branch June 18, 2026 00:15
github-actions Bot added a commit that referenced this pull request Jun 18, 2026
crs48 added a commit that referenced this pull request Jun 18, 2026
## What

Backfills the changelog so it reflects what's live on `main`. The per-PR
fragment system (exploration 0197) was current through PR #163 (PR #178
backfilled #151/#153/#156/#157/#160/#163), but the recent **0198/0199
batch** and a couple of stragglers shipped without entries. This adds 7
fragments, with prose lifted from each PR's own `## Changelog` block
where present.

| PR | Entry | Tags |
|---|---|---|
| #143 | AI chat that knows your workspace | `ai` |
| #167 | Linear-grade Tasks | `tasks` `app` |
| #169 | Cleaner page editor typography | `editor` |
| #170 | Bring your Slack data into xNet | `plugins` `platform` |
| #171 | Smoother, more consistent motion | `app` |
| #173 | Airtable-grade database grids | `app` |
| #174 | A redesigned chat & channels experience | `app` |

## Deliberately excluded

- **In-editor AI (#154 / #158)** — shipped the transform engine +
diff/approval logic, but both PRs state the `/ai` UI wiring into the
running app is **deferred**, so it isn't user-visible yet. An entry
would overclaim.
- **Slack (#170)** — entry scoped to the migration connector +
compatibility layer (genuinely live); avoids claiming the
webhook/slash-command hub endpoints work, since they're
generic-over-an-injected-sink and not mounted in a running server yet.
- Pure infra/ops/docs PRs (cloud M1/M2, CI, runbooks) — not user-facing.

## Verification

- `validate-changelog.ts` → **26 fragments valid** (19 existing + 7 new)
- `prettier --check` on all new files → clean
- Newest-first ordering confirmed — the 7 entries slot in at the top

## Changelog

<!-- This PR only adds changelog fragments for already-merged work; it
ships no user-facing change of its own. -->

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants