@gradeui/ui 2.0.0 → 3.0.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/README.md CHANGED
@@ -27,23 +27,16 @@ export default function Example() {
27
27
  }
28
28
  ```
29
29
 
30
- ### Tailwind preset
30
+ ### Styles
31
31
 
32
- If you're using Tailwind in your consuming app, extend the Grade preset so brand tokens and OKLCH semantic colors resolve correctly:
32
+ Import the compiled stylesheet once (e.g. in your root layout). It is fully self-contained — design tokens, the native Tailwind v4 `@theme` bridge, and every utility the components use are baked in; there is no JS Tailwind config to extend (the old `@gradeui/ui/tailwind-preset` export was retired with the v4 native-@theme migration):
33
33
 
34
34
  ```ts
35
- // tailwind.config.ts
36
- import gradePreset from "@gradeui/ui/tailwind-preset";
37
-
38
- export default {
39
- presets: [gradePreset],
40
- content: [
41
- "./app/**/*.{ts,tsx,mdx}",
42
- "./node_modules/@gradeui/ui/dist/**/*.{js,mjs}",
43
- ],
44
- };
35
+ import "@gradeui/ui/styles.css";
45
36
  ```
46
37
 
38
+ If your app runs its own Tailwind v4 build alongside Grade, add a `@source` for `node_modules/@gradeui/ui/dist` so your scan picks up the library's class names — the brand and semantic tokens themselves already ship in the stylesheet as CSS variables (`--gds-*`, OKLCH role triplets).
39
+
47
40
  ## Theme engine
48
41
 
49
42
  `@gradeui/ui` ships an OKLCH-based theme generator. Wrap your app in `GradeThemeProvider` (currently still named `GradeThemeProvider` pending rename — see upstream TODO) to get runtime theme switching.
@@ -2,16 +2,23 @@
2
2
  name: Avatar
3
3
  import: "@gradeui/ui"
4
4
  subcomponents: [AvatarImage, AvatarFallback]
5
+ sizes: [2xs, xs, sm, md, lg, xl]
5
6
  props:
6
- - Avatar: className? — set size via utilities (default h-10 w-10)
7
+ - size? (2xs | xs | sm | md | lg | xl) t-shirt scale, 20px 80px; default md (40px). xs for chat message rows, sm for comments/dense threads, lg/xl for profile headers. Prefer this over h-*/w-* className utilities.
7
8
  - AvatarImage: src, alt
8
- - AvatarFallback: initials/icon rendered when image fails or loads
9
+ - AvatarFallback: tone? (muted | primary | violet | amber | emerald | sky | rose | plum | lime) — tinted bg/text pair. Reach for explicit tones when each author needs a stable colour mapping (chat avatars, comment threads, member lists); default muted.
10
+ - AvatarFallback: children — initials (or a small icon), rendered while the image loads or when it fails
9
11
  when_to_use: User/entity identity for PEOPLE — profile pictures, author rows, member lists, account headers. Circular by default; the AvatarFallback initials read as a person's name. Always include AvatarFallback so load failure doesn't leave a gap.
10
- composes_with: [Card (in CardHeader), Table cells, Badge (placed next to for status), Skeleton (loading state)]
12
+ composes_with: [Card (in CardHeader), Table cells, Badge (placed next to for status), Skeleton (loading state), Message (in the avatar slot)]
11
13
  aliases: [profile picture, user image, account image, avatar, person glyph, user avatar, profile image, react native avatar]
12
14
  notes: |
13
15
  Anti-patterns to avoid:
14
16
 
17
+ - DO NOT pass `initials` as a prop on <Avatar> — that prop does not
18
+ exist. Initials are the CHILDREN of <AvatarFallback>:
19
+ `<Avatar><AvatarFallback>AL</AvatarFallback></Avatar>`.
20
+ - DO NOT size with className utilities (h-7 w-7) — use the `size`
21
+ prop so the scale stays on the t-shirt tokens.
15
22
  - DO NOT use Avatar for album art, posters, product photos, landscape
16
23
  images, or anything that isn't a person. Use <MediaSurface> with the
17
24
  appropriate `hint` ("album", "poster", "product", "landscape", etc.) —
@@ -27,3 +34,21 @@ notes: |
27
34
  <AvatarFallback>AL</AvatarFallback>
28
35
  </Avatar>
29
36
  ```
37
+
38
+ ```jsx
39
+ // Chat / comment rows — small size + stable per-author tone.
40
+ <Avatar size="xs">
41
+ <AvatarFallback tone="violet">A</AvatarFallback>
42
+ </Avatar>
43
+ <Avatar size="sm">
44
+ <AvatarFallback tone="amber">B</AvatarFallback>
45
+ </Avatar>
46
+ ```
47
+
48
+ ```jsx
49
+ // Profile header — large, with image + initials fallback.
50
+ <Avatar size="lg">
51
+ <AvatarImage src="/ali.jpg" alt="Ali Driver" />
52
+ <AvatarFallback tone="primary">AD</AvatarFallback>
53
+ </Avatar>
54
+ ```
@@ -0,0 +1,31 @@
1
+ # GradeLoader
2
+
3
+ The branded indeterminate loader — the Grade G-arrow mark with a diagonal
4
+ shimmer sweeping through it. Use it for EVERY "working, unknown duration"
5
+ moment instead of a generic spinner: fetching, compiling, warming a shader,
6
+ waiting on AI.
7
+
8
+ props:
9
+ - size?: "sm" | "md" | "lg" | "xl" | number — mark size (16/24/32/48px). Default "md".
10
+ - label?: string — accessible status text; shown visually with showLabel. Default "Loading…"; pass "" to silence.
11
+ - showLabel?: boolean — render the label as a caption under the mark. Default false.
12
+
13
+ when_to_use:
14
+ - Indeterminate waits: data fetching, AI generation in flight, preview compiling, media/shader warm-up, route transitions.
15
+ - Centered in an empty panel/card region while its content loads (pair with size="lg" + showLabel).
16
+ - NOT for determinate progress — use Progress when you can show a fraction.
17
+ - NOT a skeleton — use Skeleton when the layout shape is known and content is imminent.
18
+
19
+ composes_with:
20
+ - Card / panel bodies (centered placeholder state)
21
+ - Button (size="sm" inline while an action is pending)
22
+ - Motion scene boundaries / media surfaces while heavy content warms
23
+ - EmptyState (loading precursor before empty/error variants)
24
+
25
+ aliases: loader, spinner, loading indicator, busy, indeterminate, grade mark loader, branded spinner
26
+
27
+ notes:
28
+ - Paints with currentColor — set text colour on a parent (`text-muted-foreground`, `text-white` over footage).
29
+ - The shimmer highlights with oklch(var(--brand-1)) when brand pops are present; degrades to currentColor.
30
+ - prefers-reduced-motion swaps the sweep for a gentle opacity pulse.
31
+ - Announces via role="status"; the label is always available to screen readers.
@@ -3,7 +3,8 @@ name: Input
3
3
  import: "@gradeui/ui"
4
4
  props:
5
5
  - type?: string (text | email | password | number | search | url | tel | date)
6
- - size?: "default" | "sm" | "xs" control density. `default` (h-9) for forms; `sm` (h-8) and `xs` (h-7) for dense tool panels like the inspector.
6
+ - placeholder?: string hint text shown while the input is empty. Model it explicitly (not just a native passthrough) so generated screens carry placeholders and the validator accepts them.
7
+ - size?: "default" | "sm" | "xs" — control density. `default` (h-9) for forms; `sm` (h-8) and `xs` (h-7) for dense tool panels like the inspector. NOTE: pre-unification scale — see Figma parity audit; due to migrate to the t-shirt scale (xs 24 | sm 28 | md 32 | lg 40, default→md).
7
8
  - startSlot?: ReactNode — adornment rendered inside the leading edge (icon, prefix, currency symbol). Non-interactive by default so clicks focus the input.
8
9
  - endSlot?: ReactNode — adornment rendered inside the trailing edge (unit like "px", a clear button, a stepper). Same pointer rules as startSlot.
9
10
  - All native input HTML attrs (value, onChange, placeholder, disabled, required)
@@ -5,9 +5,10 @@ subcomponents: []
5
5
  props:
6
6
  - sources?: LogoSources — artwork keyed by lockup then appearance:
7
7
  { square?: { light?, dark?, mono? }, horizontal?: {...}, icon?: {...} }.
8
- Each slot is any node (inline <svg>, <img>, component). Omit entirely
9
- and a neutral "Logo" placeholder renders (use this in prototypes
10
- before real artwork exists).
8
+ Each slot is any node (inline <svg>, <img>, component). OMIT ENTIRELY
9
+ and the GRADE MARK renders (the square G-arrow, painted with
10
+ currentColor so it sits correctly on any surface). A bare <Logo /> is
11
+ always correct branding.
11
12
  - lockup?: "square" | "horizontal" | "icon" (default "horizontal")
12
13
  - mode?: "light" | "dark" (default "light") — the background the logo sits on
13
14
  - mono?: boolean (default false) — use the single-colour artwork (inherits currentColor)
@@ -16,19 +17,31 @@ props:
16
17
  - decorative?: boolean — aria-hidden when the name is already nearby
17
18
  - href?: string — renders the logo as a link (logo-links-home)
18
19
  - className?: string
19
- when_to_use: A brand mark with built-in variations a square mark for tight
20
- spaces, a horizontal lockup for headers, monochrome for busy/inverted
21
- surfaces. Reach for Logo in toolbars, sidenav headers, and footers instead
22
- of dropping a bare <img>, so the lockup and on-dark/on-light treatment are
23
- switchable by prop. The artwork is supplied by the consumer; Logo just picks
24
- the right slot for the context.
25
- composes_with: [AppShell, AppShellHeader, Sidebar, SidebarHeader, Row, Stack]
26
- aliases: [logo, brand, brandmark, wordmark, lockup, brand logo, app logo, logotype]
20
+ when_to_use: ALWAYS use <Logo> wherever a screen carries a brand mark
21
+ app-shell headers, sidenav headers, toolbars, footers, sign-in pages, hero
22
+ navs, splash states, Motion network-bug overlays. NEVER fake a brand with
23
+ placeholder text, initials in a circle, a generic lucide icon, or a bare
24
+ <img>. When the user hasn't named a brand or supplied artwork, render a
25
+ bare <Logo /> it defaults to the GRADE mark (the square G-arrow), which
26
+ is the intended branding for unbranded screens. When the user names their
27
+ own brand, pass their artwork via `sources` (with a `label`). Built-in
28
+ variations — square for tight spaces, horizontal lockup for headers,
29
+ monochrome for busy/inverted surfaces — all switchable by prop.
30
+ composes_with: [AppShell, AppShellHeader, Sidebar, SidebarHeader, Toolbar, MotionOverlay, Row, Stack]
31
+ aliases: [logo, brand, brandmark, wordmark, lockup, brand logo, app logo, logotype, grade mark, g arrow]
27
32
  ---
28
33
 
29
34
  ```jsx
30
- // Sidenav header: square mark when collapsed, horizontal when expanded.
31
- // Supply your own artwork per slot; here inline SVGs stand in.
35
+ // THE DEFAULT no brand named, no artwork supplied: the Grade mark.
36
+ // Size it and set the surrounding text colour; nothing else needed.
37
+ <Row gap="sm" align="center" className="text-foreground">
38
+ <Logo lockup="square" size="sm" decorative />
39
+ <span className="text-sm font-semibold">Grade</span>
40
+ </Row>
41
+ ```
42
+
43
+ ```jsx
44
+ // A branded screen: supply the brand's own artwork per slot.
32
45
  <Logo
33
46
  lockup="horizontal"
34
47
  mode="dark"
@@ -42,8 +55,21 @@ aliases: [logo, brand, brandmark, wordmark, lockup, brand logo, app logo, logoty
42
55
  />
43
56
  ```
44
57
 
58
+ ```jsx
59
+ // In a Motion's broadcast layer — the network bug is a Logo, not a div.
60
+ <MotionOverlay zone="top-right">
61
+ <span className="text-white">
62
+ <Logo lockup="square" size="sm" label="Grade" />
63
+ </span>
64
+ </MotionOverlay>
65
+ ```
66
+
45
67
  ### Anti-patterns
46
68
 
69
+ DO NOT fake a brand mark with initials in a circle, placeholder text like
70
+ "LOGO", or a generic lucide icon — `<Logo />` with no props IS the correct
71
+ unbranded default (it renders the Grade mark).
72
+
47
73
  DO NOT drop a bare `<img src="logo.png">` in a toolbar/sidenav/footer when you
48
74
  want light/dark or square/horizontal switching — use `<Logo>` so the variant
49
75
  is a prop.
@@ -4,16 +4,22 @@ import: "@gradeui/ui"
4
4
  subcomponents: [MapMarker]
5
5
  aliases: [map, maps, mapbox, maplibre, google maps, geo, location, latlng, coordinates, marker, pin, airbnb, listings, fleet, real estate, logistics, map view, mapkit, mapview, react native maps, rn maps]
6
6
  props:
7
- - provider — "maplibre" (default, free, no key) | "mapbox" (needs accessToken) | "google" (needs apiKey). Switching is one prop change.
8
- - center — `[lng, lat]` tuple. ALWAYS lng first. Required.
9
- - zoom — number, 0–22. Required.
10
- - bounds — `[[swLng, swLat], [neLng, neLat]]`. When set, takes precedence over center+zoom.
11
- - appearance — "light" | "dark" | "satellite" | "auto" (default "auto", follows GradeThemeProvider mode).
12
- - hoveredId — controlled string id, pairs with onHoveredIdChange. The matching MapMarker gets `data-gds-state="hovered"` automatically. This is how you build list ↔ map two-way sync.
13
- - interactive — false freezes pan/zoom, useful for static cards.
14
- - onLoad(handle) / onError(error) — handle exposes flyTo, panTo, fitBounds, getCenter, getZoom, getBounds, instance.
15
- - tilerKey (maplibre) only needed off `gradeui.com`/`localhost`. Default key is referrer-locked.
16
- - accessToken (mapbox), apiKey (google) required for those providers.
7
+ - Map: provider — "maplibre" (default, free, no key) | "mapbox" (needs accessToken) | "google" (needs apiKey). Switching is one prop change.
8
+ - Map: center — `[lng, lat]` tuple. ALWAYS lng first. Required.
9
+ - Map: zoom — number, 0–22. Required.
10
+ - Map: bounds — `[[swLng, swLat], [neLng, neLat]]`. When set, takes precedence over center+zoom.
11
+ - Map: appearance — "light" | "dark" | "satellite" | "auto" (default "auto", follows GradeThemeProvider mode).
12
+ - Map: hoveredId — controlled string id, pairs with onHoveredIdChange. The matching MapMarker gets `data-gds-state="hovered"` automatically. This is how you build list ↔ map two-way sync.
13
+ - Map: interactive — false freezes pan/zoom, useful for static cards.
14
+ - Map: onLoad(handle) / onError(error) — handle exposes flyTo, panTo, fitBounds, getCenter, getZoom, getBounds, instance.
15
+ - Map: tilerKey? — MapLibre only (provider="maplibre"). Optional everywhere: omit on `gradeui.com`/`localhost` and the referrer-locked demo key is used; set it only when embedding off-domain. The contract never requires it.
16
+ - Map: accessToken? — Mapbox only. Pass it whenever provider="mapbox" — the component itself enforces this at runtime (throws a clear `provider="mapbox" requires an accessToken prop` error via onError if missing). It is OPTIONAL in the contract on purpose, so the validator never demands it from maplibre/google maps.
17
+ - Map: apiKey? — Google only. Pass it whenever provider="google" — the component enforces it at runtime (throws `provider="google" requires an apiKey prop` via onError if missing). OPTIONAL in the contract on purpose, so it's never demanded from maplibre/mapbox.
18
+ - MapMarker: id — string. Required. Stable marker id; pair with Map's `hoveredId` for list↔map hover sync.
19
+ - MapMarker: at — `[lng, lat]` tuple. Required. THE coordinate prop. ALWAYS lng first. The prop is literally named `at` — it is NOT `lngLat`, `coordinates`, `position`, `latLng`, `center`, or separate `lng`/`lat` props. Passing any other name leaves the marker coord `undefined`, and MapLibre throws on mount, crashing the WHOLE screen in every renderer. When in doubt, copy the `airbnb-listings` scaffold: `<MapMarker id={l.id} at={l.coords}>`.
20
+ - MapMarker: anchor — "center" | "bottom" (default "bottom", pin tip sits on the coord). Only these two values.
21
+ - MapMarker: onClick — handler called with `({ id, coords, native })` on marker click.
22
+ - MapMarker: children — DOM rendered as the marker (Badge, Card, Avatar, or any element). Inherits `--gds-*` tokens.
17
23
  when_to_use: Any layout that needs a real map — listings (real estate, Airbnb-style), fleet/logistics dashboards, store locators, anywhere a user picks a location from a viewport. Reach for the controlled `hoveredId` prop when a sibling list and the map need to highlight each other.
18
24
  composes_with: [Card (as marker content), Badge, Avatar, Button, Row, Stack, Skeleton]
19
25
  ---
@@ -69,8 +75,11 @@ Provider swap — one line:
69
75
  <Map provider="google" apiKey={env.GOOGLE_MAPS_KEY} center={[-0.1, 51.5]} zoom={11} />
70
76
  ```
71
77
 
78
+ The contract is deliberately provider-AGNOSTIC. `tilerKey`, `accessToken`, and `apiKey` are all OPTIONAL in the contract so any valid provider config validates — a maplibre map needs no key on-domain, a mapbox map carries `accessToken`, a google map carries `apiKey`, and none of them trip a `missing-required` error for a key another provider uses. The knowledge of which key a provider needs lives in the component/adapter at runtime, not the static contract: maplibre falls back to the referrer-locked demo key; mapbox throws `provider="mapbox" requires an accessToken prop`; google throws `provider="google" requires an apiKey prop` — each surfaced via `onError({ code: "api-key-missing" })`. Maintainers: do NOT re-mark these credentials required in the contract — that's the bug that blanket-required all three and broke on-domain maplibre maps.
79
+
72
80
  ANTI-PATTERNS — don't do these:
73
81
 
82
+ - DO NOT name the marker coordinate prop anything other than `at`. It is `<MapMarker id="…" at={[lng, lat]} />` — NOT `lngLat`, `coordinates`, `position`, `latLng`, or `center`. A wrong name passes JSX validation (the validator only checks `<Map>`'s contract, not subcomponent prop names) but registers an `undefined` coord, so MapLibre throws on mount and the whole screen fails to render.
74
83
  - DO NOT pass `{ lat, lng }` objects. Coordinates are ALWAYS `[lng, lat]` tuples. Google's adapter handles the object conversion internally.
75
84
  - DO NOT hand-roll an iframe with a Google Maps embed URL. Use `<Map provider="google" apiKey={...}>`.
76
85
  - DO NOT use `useRef` + `mapRef.current.flyTo(id)` on list-hover when `hoveredId` already does it controlled.
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: Motion
3
+ import: "@gradeui/ui"
4
+ subcomponents: [MotionScene, MotionScreen, MotionText]
5
+ props:
6
+ - view?: "play" | "strip" (default "play") — play runs the film; strip lays
7
+ scenes out left-to-right as labelled cards (the arrangement view).
8
+ - aspect?: "auto" | "16/9" | "9/16" | "1/1" (default "auto") — fixed artboard
9
+ aspect, letterboxed into the container. "9/16" is the TikTok / Reels /
10
+ Shorts format; "auto" fills responsively. Strip cards adopt the ratio.
11
+ - stage?: string — CSS background of the persistent stage behind every scene.
12
+ - backdrop?: React.ReactNode — live layer behind all scenes (image, gradient, <ThreeScene>).
13
+ - autoplay?: boolean (default true)
14
+ - loop?: boolean (default false — a motion is a movie, it ends)
15
+ - controls?: boolean (default true) — play/pause, restart, scene dots (random access)
16
+ - children: <MotionScene> elements, in order.
17
+ - "MotionScene: label?, durationMs? (MINIMUM runtime; the whole clock when
18
+ nothing inside keeps time, default 4000), fill? (scene background over
19
+ the stage — e.g. a white title card), transition? ('fade' | 'slide-up' |
20
+ 'slide-down' | 'slide-left' | 'slide-right' | 'pop' | 'zoom' |
21
+ 'wipe-circle' (a circular mask wipe) | 'none' — how the scene ARRIVES.
22
+ The OUTGOING scene stays visible as a frozen layer UNDERNEATH for the
23
+ transition window, so slides reveal it and wipes cut through it),
24
+ transitionMs? (timing override; each transition has a sensible
25
+ default), children (ANY JSX)."
26
+ - "MotionScreen: device? ('desktop' | 'mobile'), shots? (its OWN ScreenAnimator
27
+ camera), virtualWidth?, spotlight?, cursor?, enter? (default FALSE —
28
+ the offscreen fly-in reads badly inside a small frame; use scene
29
+ transition / animate for entrances), animate? ('rise' |
30
+ 'tilt-settle' — entrances; 'float' | 'drift' — ambient loops; 'none'
31
+ default — animates the FRAME in place within the scene, composable with
32
+ the camera inside; pair entrances with enter={false}), screenId?
33
+ (provenance, ignored at render), children (the screen content, copied in)."
34
+ - "MotionText: template? ('title' | 'lower-third' | 'section-break' |
35
+ 'broadcast' — the TV-style full-width brand-blue band that sits over
36
+ the screen | 'ticker' — a news-style marquee bar pinned to the very
37
+ bottom: heading is the uppercase label chip, text scrolls in an
38
+ infinite loop | 'stat' — an oversized statistic slate: heading is the
39
+ number slamming in at up to 180px, text is the label fading up below |
40
+ 'quote' — an editorial pull-quote with a decorative oversized opening
41
+ mark: heading is the quote, text the em-dash attribution), heading,
42
+ text?, durationMs?, tone? ('light' | 'dark'). 'ticker' pairs well
43
+ inside MotionOverlay zone='bottom' for a film-level ticker that runs
44
+ across every scene."
45
+ - "MotionOverlay: the BROADCAST layer — a peer of MotionScene inside
46
+ <Motion> that renders above every scene for the film's runtime:
47
+ network-bug logo, live wall clock (which keeps ticking when playback
48
+ pauses — better-than-video proof it's live), ticker, persistent
49
+ video. zone? ('top-left' | 'top' | 'top-right' | 'center' |
50
+ 'bottom-left' | 'bottom' | 'bottom-right' | 'lower-third'),
51
+ fromScene?/toScene? (scene-range visibility — overlays are a second
52
+ timeline; defaults = always on), interactive? (re-enable pointer
53
+ events), children (any JSX)."
54
+ when_to_use: A directed sequence of scenes on one persistent stage — the
55
+ text → demo → video → text grammar of a modern product demo. A scene is a
56
+ stage MOMENT holding arbitrary JSX; screens go inside scenes via
57
+ <MotionScreen> (each with its own camera — two side by side shows
58
+ mobile + desktop), templated text via <MotionText>, video/images as plain
59
+ children. A scene advances when all its timed children finish (camera tours,
60
+ text templates), or after durationMs when nothing keeps time. Use
61
+ <ScreenAnimator> alone for a single directed screen; use <Motion> the moment
62
+ there's a sequence.
63
+ composes_with: [ScreenAnimator, ThreeScene, VideoPlayer, AppShell, the whole component set (scenes hold screens)]
64
+ aliases: [motion, grade motion, scenes, sequence, demo reel, product video, launch video, title card, lower third, section break, multi-scene, storyboard]
65
+ ---
66
+
67
+ ```jsx
68
+ // Title card → dashboard at two viewports → video clip. One stage throughout.
69
+ <Motion>
70
+ <MotionScene label="Hook">
71
+ <MotionText template="title" heading="Meet the new pipeline" text="From prompt to product" />
72
+ </MotionScene>
73
+ <MotionScene label="Dashboard">
74
+ <MotionScreen device="mobile" shots={[{ zoom: 1, hold: 2000 }, { zoom: 2, cx: 0.5, cy: 0.25, hold: 2400, label: "Live on mobile" }]}>
75
+ <DashboardMobile />
76
+ </MotionScreen>
77
+ <MotionScreen shots={[{ zoom: 1, hold: 2400 }, { zoom: 2.2, cx: 0.22, cy: 0.3, hold: 2600, label: "Revenue up 24%" }]}>
78
+ <Dashboard />
79
+ </MotionScreen>
80
+ </MotionScene>
81
+ <MotionScene label="Proof">
82
+ <MotionText template="stat" heading="4.2x" text="Faster from prompt to product" />
83
+ </MotionScene>
84
+ <MotionScene label="Word">
85
+ <MotionText template="quote" heading="It feels like the demo directs itself." text="Head of Design, Acme" />
86
+ </MotionScene>
87
+ <MotionScene label="Clip" durationMs={6000}>
88
+ <video src="/clip.mp4" autoPlay muted style={{ borderRadius: 12, maxWidth: "70%" }} />
89
+ <MotionText template="ticker" heading="Live" text="Grade Motion ships scene transitions, broadcast overlays and a directed camera" />
90
+ </MotionScene>
91
+ </Motion>
92
+ ```
93
+
94
+ ### Anti-patterns
95
+
96
+ DO NOT wrap a whole scene in <ScreenAnimator> — the camera belongs to each
97
+ <MotionScreen> inside the scene, not to the scene. A scene with two screens
98
+ has two cameras.
99
+
100
+ durationMs is a MINIMUM runtime, not just a fallback: a scene with a 3s
101
+ lower-third and `durationMs={16000}` runs the full 16s (the caption ending
102
+ early never cuts a long visual mid-flight). Timed children can extend a
103
+ scene PAST the floor; with no timed children, durationMs is the whole clock.
104
+
105
+ DO NOT use it as a layout wrapper — like ScreenAnimator it positions
106
+ `absolute inset-0` and takes over the frame.
107
+
108
+ DO NOT worry about reduced motion — the play view falls back to the strip
109
+ (see everything, move nothing).