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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
- name: Plugin license policy (0196)
run: pnpm check:plugin-licenses

- name: Motion vocabulary (0199)
run: pnpm check:motion-vocab

# The heavy half of the old lint job: build the workspace and typecheck it.
# typecheck genuinely needs `^build` (turbo builds dependencies first), so it
# stays coupled to the build. Runs in parallel with `lint`, `test`, and
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/DataWorkspaceView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,7 @@ function SocialImportJobsPanel({ jobs }: { jobs: SocialImportJobProgress[] }): J
aria-label={`Import progress for ${job.archiveName}`}
>
<div
className="h-full rounded-full bg-primary transition-[width] duration-200"
className="h-full rounded-full bg-primary transition-[width] duration-slow"
style={{ width: `${percent}%` }}
/>
</div>
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/components/StorageWarningBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Presence } from '@xnetjs/ui'
import { AlertTriangle, CheckCircle2, Info, ShieldCheck, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'

Expand Down Expand Up @@ -126,14 +127,13 @@ export function StorageWarningBanner({
? `${formatBytes(usageBytes)} used of ${formatBytes(quotaBytes)} available`
: null

if (dismissed) return null

return (
<div
ref={rootRef}
<Presence
show={!dismissed}
motion="slide-down"
className={`pointer-events-none fixed top-0 left-0 right-0 z-50 border-b ${toneClasses.container}`}
>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div ref={rootRef} className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-start flex-1 min-w-0">
<span className={`flex p-2 rounded-lg ${toneClasses.icon}`}>
Expand Down Expand Up @@ -204,6 +204,6 @@ export function StorageWarningBanner({
</button>
</div>
</div>
</div>
</Presence>
)
}
51 changes: 32 additions & 19 deletions apps/web/src/components/UndoToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* keyboard hint reminds users the action is reversible.
*/
import { useGlobalUndo } from '@xnetjs/react'
import { Presence } from '@xnetjs/ui'
import {
createContext,
useCallback,
Expand Down Expand Up @@ -57,28 +58,40 @@ export function UndoToastProvider({ children }: { children: ReactNode }): JSX.El
await undo()
}, [undo])

// Latch the last toast so its text survives the exit animation, when
// `toast` has already flipped to null but <Presence> is still animating out.
// Horizontal centering uses auto-margins (not -translate-x-1/2) so the
// slide-up keyframe's translateY animates cleanly without fighting a static
// transform.
const lastToastRef = useRef<{ id: number; message: string } | null>(null)
if (toast) lastToastRef.current = toast
const shown = toast ?? lastToastRef.current

return (
<UndoToastContext.Provider value={{ showUndoToast }}>
{children}
{toast ? (
<div
role="status"
aria-live="polite"
className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-lg border border-border bg-background px-4 py-2.5 text-sm text-foreground shadow-2xl"
>
<span>{toast.message}</span>
<button
type="button"
onClick={() => void handleUndo()}
className="cursor-pointer rounded-md border-none bg-transparent p-0 font-semibold text-primary hover:underline"
>
Undo
</button>
<kbd className="rounded border border-border bg-secondary px-1.5 py-0.5 text-xs text-muted-foreground">
⌘Z
</kbd>
</div>
) : null}
<Presence
show={toast != null}
motion="slide-up"
wrapperProps={{ role: 'status', 'aria-live': 'polite' }}
className="fixed bottom-4 left-0 right-0 z-50 mx-auto w-fit"
>
{shown ? (
<div className="flex items-center gap-3 rounded-lg border border-border bg-background px-4 py-2.5 text-sm text-foreground shadow-2xl">
<span>{shown.message}</span>
<button
type="button"
onClick={() => void handleUndo()}
className="cursor-pointer rounded-md border-none bg-transparent p-0 font-semibold text-primary hover:underline"
>
Undo
</button>
<kbd className="rounded border border-border bg-secondary px-1.5 py-0.5 text-xs text-muted-foreground">
⌘Z
</kbd>
</div>
) : null}
</Presence>
</UndoToastContext.Provider>
)
}
2 changes: 1 addition & 1 deletion apps/web/src/routes/social-import.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,7 @@ function CommitProgressPanel({ progress }: { progress: CommitProgress }): React.
aria-label="Commit progress"
>
<div
className="h-full rounded-full bg-primary transition-[width] duration-200"
className="h-full rounded-full bg-primary transition-[width] duration-slow"
style={{ width: `${percent}%` }}
/>
</div>
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/workbench/TabBreadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import { FolderSchema } from '@xnetjs/data'
import { useQuery } from '@xnetjs/react'
import { useViewTransition } from '@xnetjs/ui'
import { FolderClosed, Users } from 'lucide-react'
import { Fragment, useMemo } from 'react'
import { useSpaces } from '../hooks/useSpaces'
Expand Down Expand Up @@ -53,6 +54,7 @@ export function TabBreadcrumb({ tab }: { tab: WorkbenchTab | null }) {
const { folderNames, spaceId } = useBreadcrumb(tab)
const { getSpace } = useSpaces()
const setCurrentSpace = useWorkbench((state) => state.setCurrentSpace)
const withTransition = useViewTransition()
const space = getSpace(spaceId)

if (!space && folderNames.length === 0) return null
Expand All @@ -62,7 +64,7 @@ export function TabBreadcrumb({ tab }: { tab: WorkbenchTab | null }) {
{space ? (
<button
type="button"
onClick={() => setCurrentSpace(space.id)}
onClick={() => withTransition(() => setCurrentSpace(space.id))}
title={`Filter to ${space.name}`}
className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-ink-3 hover:text-ink-1"
>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/workbench/views/TodayPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function StrengthBar({ value }: { value: number }): JSX.Element {
return (
<div className="h-1 w-full overflow-hidden rounded-full bg-accent">
<div
className="h-full rounded-full bg-[var(--primary,#6366f1)] transition-all"
className="h-full rounded-full bg-[var(--primary,#6366f1)] transition-[width] duration-slow"
style={{ width: `${Math.round(Math.min(1, Math.max(0, value)) * 100)}%` }}
/>
</div>
Expand Down
140 changes: 140 additions & 0 deletions docs/MOTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Motion Style Guide

xNet's animation vocabulary. It is deliberately small: the same restraint that
makes the UI feel designed makes motion easy to author consistently — by a
person or an agent. If you can't express an animation with what's below, you're
probably over-animating.

Source of truth: [`packages/ui/src/theme/motion.css`](../packages/ui/src/theme/motion.css).
Enforcement: [`scripts/check-motion-vocab.mjs`](../scripts/check-motion-vocab.mjs)
(runs in CI). Origin: [exploration 0199](explorations/0199_[_]_ELEGANT_COMPOSABLE_MOTION_SYSTEM.md).

## The two laws

1. **Enter is slower and decelerates** — `ease-out`, `duration-normal` (150ms).
2. **Exit is faster and accelerates** — `ease-in`, `duration-fast` (100ms).

Motion that arrives gently and leaves briskly feels intentional. The reverse
feels broken. Every primitive below already bakes this in.

## The vocabulary

### Durations (everyday set in bold)

| Token | Value | Use |
|---|---|---|
| `duration-fast` | **100ms** | hover / press feedback, **exits** |
| `duration-normal` | **150ms** | standard **enter** |
| `duration-slow` | **200ms** | emphasis enter — panels, dialogs, sheets |
| `duration-instant` | 0ms | edge case |
| `duration-slower` | 300ms | large background moves |
| `duration-slowest` | 400ms | rare, dramatic |

### Easings

| Token | Use |
|---|---|
| `ease-out` | **enters** (decelerate in) |
| `ease-in` | **exits** (accelerate away) |
| `ease-in-out` | moves / morphs (something already on screen relocating) |
| `ease-spring` | **direct-manipulation feedback only** — a toggle thumb, a checkbox pop, a drag pickup. Never ambient enters. |
| `linear` | continuous loops (spinner, shimmer, marquee) |

> `ease-bounce` was retired. Its negative anticipation is the opposite of
> "minimal"; `ease-spring` covers everything that should feel springy.

### Primitives

All are compositor-only (`transform` + `opacity`) so they stay at 60fps:

`fade` · `scale` (0.95→1) · `slide-up` · `slide-down` · `slide-left` ·
`slide-right` · `collapse` (height — accordion/disclosure) · `pop` (spring
scale, for direct manipulation) · `shimmer` (skeletons) · `spin` (loaders) ·
`pulse-subtle` (status/attention).

## How to apply motion

### 1. Hover / press / state — Tailwind utilities

```tsx
// Use the shared transition utilities (NOT transition-all).
<button className="transition-base hover:bg-surface-2" /> // colors+opacity+shadow+transform
<button className="transition-colors-fast hover:text-ink-1" /> // color only, fast
<div className="transition-transform data-[open]:rotate-180" /> // transform+opacity
```

Need a specific property? Name it explicitly: `transition-[width]`,
`transition-[opacity,transform]`. Never `transition-all` — it silently animates
layout properties (`width`/`height`/`top`) off the compositor and janks.

### 2. Open / close of a Base UI component — already done

Dialogs, popovers, tooltips, menus, selects, accordions, switches, checkboxes
animate via [`base-ui-animations.css`](../packages/ui/src/theme/base-ui-animations.css)
(`data-open` / `data-ending-style`). You get enter/exit for free.

### 3. A React conditional that mounts/unmounts — `<Presence>`

For things outside Base UI (toasts, banners, ad-hoc panels), `{open && <X/>}`
can't animate out — the node is gone instantly. Use `<Presence>`:

```tsx
import { Presence } from '@xnetjs/ui'

<Presence show={toast != null} motion="slide-up" wrapperProps={{ role: 'status' }}>
<ToastBody … />
</Presence>
```

`motion`: `fade` (default) · `scale` · `slide-up` (rises from below — toasts) ·
`slide-down` (descends from above — top banners) · `pop`.

### 4. A list that adds/removes/reorders — `.stagger`

```tsx
<ul className="stagger">
{items.map((it, i) => (
<li key={it.id} style={{ '--i': i }}>{it.label}</li>
))}
</ul>
```

### 5. A discrete surface / list swap — `useViewTransition()`

```tsx
import { useViewTransition } from '@xnetjs/ui'

const withTransition = useViewTransition()
const reScope = (id: string) => withTransition(() => setCurrentSpace(id))
```

Cross-fades the change via the native View Transitions API; falls back to an
instant swap where unsupported or under reduced motion. Use for discrete,
user-initiated swaps — not high-frequency updates.

## Accessibility

`prefers-reduced-motion: reduce` collapses every animation to ~instant globally
(motion.css). You don't need per-component handling. State still changes; it
just doesn't move. `usePrefersReducedMotion()` is available for custom logic
(`useViewTransition` already uses it).

## When you genuinely need more (the escape hatch)

Drag-coupled motion and FLIP layout animations are the ~5% this vocabulary
doesn't cover. Reach for `motion/react` (LazyMotion + `m`, ~4.6KB shell) **only**
there, and **only behind a lazy/code-split boundary** so it never weighs down
the default bundle. Everything else stays CSS-first.

## For AI agents

When asked to animate something in `apps/web` or `packages/ui`:

- Compose from the tokens and primitives above. Do **not** invent keyframes,
reach for arbitrary `animate-[…]`, use `transition-all`, write raw
`duration-<ms>` literals (use `duration-fast|normal|slow`), or use
`ease-bounce`. `scripts/check-motion-vocab.mjs` fails CI on these.
- Enter → `ease-out` + `duration-normal`. Exit → `ease-in` + `duration-fast`.
- Mount/unmount in React → `<Presence>`. Base UI open/close → already handled.
- Spring is for things the user is directly pushing, nothing else.
- Default to **less**. A single 150ms fade usually beats a bespoke sequence.
Loading
Loading