@gradeui/ui 1.3.0 → 2.1.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.
@@ -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
+ ```
@@ -4,7 +4,7 @@ import: "@gradeui/ui"
4
4
  props:
5
5
  - type: "none" | "solid" | "gradient" | "image" | "video" | "shader" — which paint to render (required)
6
6
  - color?: string — solid fill; a token name (`primary`, `card`, `muted`, `accent`, `secondary`, `destructive`, `background`, `transparent`) or any CSS colour
7
- - gradient?: { from?; via?; to?; angle? } — gradient stops (token names or CSS colours) + angle in degrees (default 135)
7
+ - gradient?: { from?; via?; to?; angle?; shape?; at?; size? } — stops are token names or CSS colours. shape: "linear" (default, uses `angle`, default 135°) | "radial" (uses `at` — CSS position like "top" / "30% 20%", default "center" — and optional `size` like "45rem 50rem", default farthest-corner)
8
8
  - src?: string — image or video URL
9
9
  - fit?: "cover" | "contain" | "fill" | "none" — object-fit for image/video (default "cover")
10
10
  - position?: string — CSS object/background position (default "center")
@@ -54,11 +54,24 @@ notes: |
54
54
  ### Type cheat-sheet
55
55
 
56
56
  - solid — `color` (token or CSS colour). Cheapest.
57
- - gradient — `gradient={{ from, via?, to, angle }}`. Tokens get wrapped in oklch() automatically.
57
+ - gradient — `gradient={{ from, via?, to, angle }}` for linear;
58
+ `gradient={{ shape: "radial", at: "top", from, to }}` for a radial
59
+ glow/wash. Tokens get wrapped in oklch() automatically.
58
60
  - image — `src` + `fit` / `position`; set `repeat` (+ `tileSize`) for a tiled texture.
59
61
  - video — `src` (autoplays muted + looped + inline).
60
62
  - shader — `preset` OR `fragmentShader`, + `palette` / `postPreset`. Delegates to ThreeScene.
61
63
 
64
+ Anti-patterns to avoid:
65
+
66
+ - DO NOT build gradients with arbitrary-value Tailwind classes —
67
+ `bg-[radial-gradient(45rem_50rem_at_top,theme(colors.indigo.50),white)]`
68
+ renders NOTHING in the Studio preview (no runtime Tailwind compiler) and
69
+ `theme(colors.*)` colours ignore the active Grade theme. Use
70
+ `type="gradient"` with token stops instead — themeable, and it always renders.
71
+ - DO NOT hand-roll `style={{ backgroundImage: "linear-gradient(…)" }}` on the
72
+ frame itself when a BackgroundFill child does the same job — the fill layer
73
+ keeps the paint selectable/editable as a Fill in Studio's inspector.
74
+
62
75
  `opacity` + `blendMode` apply to every type — the same two controls as
63
76
  the inspector's Blending section, so a loud shader/image can be dialled
64
77
  back to a subtle wash behind text.
@@ -92,6 +105,19 @@ notes: |
92
105
  </Card>
93
106
  ```
94
107
 
108
+ ```jsx
109
+ // Radial glow from the top of a hero — the token-true version of the
110
+ // classic `radial-gradient(45rem 50rem at top, indigo-50, white)` wash.
111
+ <section className="relative overflow-hidden">
112
+ <BackgroundFill
113
+ type="gradient"
114
+ gradient={{ shape: "radial", at: "top", size: "45rem 50rem", from: "primary", to: "background" }}
115
+ opacity={0.2}
116
+ />
117
+ <div className="relative z-10 py-24 text-center">…hero content…</div>
118
+ </section>
119
+ ```
120
+
95
121
  ```jsx
96
122
  // Image background, cover-fit, with a blend mode.
97
123
  <div className="relative h-64 overflow-hidden rounded-lg">
@@ -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)
@@ -3,9 +3,12 @@ name: Logo
3
3
  import: "@gradeui/ui"
4
4
  subcomponents: []
5
5
  props:
6
- - sources: LogoSources (required) — artwork keyed by lockup then appearance:
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).
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.
9
12
  - lockup?: "square" | "horizontal" | "icon" (default "horizontal")
10
13
  - mode?: "light" | "dark" (default "light") — the background the logo sits on
11
14
  - mono?: boolean (default false) — use the single-colour artwork (inherits currentColor)
@@ -14,19 +17,31 @@ props:
14
17
  - decorative?: boolean — aria-hidden when the name is already nearby
15
18
  - href?: string — renders the logo as a link (logo-links-home)
16
19
  - className?: string
17
- when_to_use: A brand mark with built-in variations a square mark for tight
18
- spaces, a horizontal lockup for headers, monochrome for busy/inverted
19
- surfaces. Reach for Logo in toolbars, sidenav headers, and footers instead
20
- of dropping a bare <img>, so the lockup and on-dark/on-light treatment are
21
- switchable by prop. The artwork is supplied by the consumer; Logo just picks
22
- the right slot for the context.
23
- composes_with: [AppShell, AppShellHeader, Sidebar, SidebarHeader, Row, Stack]
24
- 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]
25
32
  ---
26
33
 
27
34
  ```jsx
28
- // Sidenav header: square mark when collapsed, horizontal when expanded.
29
- // 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.
30
45
  <Logo
31
46
  lockup="horizontal"
32
47
  mode="dark"
@@ -40,8 +55,21 @@ aliases: [logo, brand, brandmark, wordmark, lockup, brand logo, app logo, logoty
40
55
  />
41
56
  ```
42
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
+
43
67
  ### Anti-patterns
44
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
+
45
73
  DO NOT drop a bare `<img src="logo.png">` in a toolbar/sidenav/footer when you
46
74
  want light/dark or square/horizontal switching — use `<Logo>` so the variant
47
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).
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: ScreenAnimator
3
+ import: "@gradeui/ui"
4
+ subcomponents: []
5
+ props:
6
+ - shots?: Array<{ zoom?, cx?, cy?, hold?, trans?, label? }> — the tour. Each
7
+ shot is a zoom (1 = fit, >1 push in), focal point cx/cy (0..1 fractions of
8
+ the content), hold (ms dwell), trans (ms glide-in), and a caption label.
9
+ Omit for a static framed view.
10
+ - autoplay?: boolean (default true)
11
+ - loop?: boolean (default true) — fly in → shots → back to start → exit → repeat
12
+ - controls?: boolean (default true) — play / pause / restart transport
13
+ - spotlight?: boolean (default false) — opt in to dim the edges (vignette) when pushed in
14
+ - cursor?: boolean (default true) — synthetic cursor pulse on detail shots
15
+ - enter?: boolean (default true) — fly in from offscreen on start
16
+ - stage?: string — CSS background of the stage behind the screen (default dark)
17
+ - backdrop?: React.ReactNode — a live layer behind the content (image, gradient, or a <ThreeScene> shader)
18
+ - className?: string
19
+ - children: React.ReactNode (the screen to animate)
20
+ when_to_use: Wrap ANY screen or section in a directed camera — a "live demo
21
+ director". Give it a list of shots and it tours them (zoom + pan) over the
22
+ live, still-interactive content, with a focus spotlight, captions, a synthetic
23
+ cursor, and play/pause. Use it to turn a built screen into an auto-playing
24
+ product demo (embed it, or drop it on a marketing page). It's the live,
25
+ editable, re-renderable answer to a screen-recording video.
26
+ composes_with: [AppShell, ThreeScene, Card, Grid, the whole component set (it wraps a screen)]
27
+ aliases: [screen animator, camera, camera tour, director, demo, product demo, zoom pan, spotlight, ken burns, presenter]
28
+ ---
29
+
30
+ ```jsx
31
+ // Wrap a live screen; the camera tours the shots and loops.
32
+ <ScreenAnimator
33
+ shots={[
34
+ { zoom: 1, cx: 0.5, cy: 0.5, hold: 2400, label: "Overview" },
35
+ { zoom: 2.4, cx: 0.2, cy: 0.34, hold: 2600, label: "Revenue up 24%" },
36
+ { zoom: 1.8, cx: 0.5, cy: 0.6, hold: 2800, label: "Pipeline" },
37
+ ]}
38
+ backdrop={<ThreeScene preset="aurora" />}
39
+ >
40
+ <Dashboard />
41
+ </ScreenAnimator>
42
+ ```
43
+
44
+ ### Anti-patterns
45
+
46
+ DO NOT use it as a layout wrapper — it positions `absolute inset-0` and takes
47
+ over the frame. It's for a whole screen/section you want to direct, not a div.
48
+
49
+ DO NOT hand-tune `trans`/`hold` per shot unless you need to — the defaults
50
+ (soft settle on overview, snappier push on detail) read well. `cx`/`cy` are the
51
+ knobs that matter; they're fractions of the screen (0 = left/top, 0.5 = centre).
52
+
53
+ DO NOT worry about reduced motion — it settles on the starter frame and stops
54
+ moving automatically under `prefers-reduced-motion`.