@odori/cli 0.0.2 → 0.0.4

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.
Files changed (41) hide show
  1. package/dist/{chunk-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
  2. package/dist/cli.js +1 -1
  3. package/dist/index.d.ts +63 -8
  4. package/dist/index.js +3 -3
  5. package/dist/registry-snapshot-MSH2EA36.js +4867 -0
  6. package/package.json +3 -3
  7. package/src/assets.ts +90 -0
  8. package/src/brand-file.ts +16 -4
  9. package/src/chunk-cache.ts +6 -0
  10. package/src/cli.ts +22 -12
  11. package/src/commands/add.ts +33 -8
  12. package/src/commands/dev.ts +114 -12
  13. package/src/commands/doctor.ts +47 -2
  14. package/src/commands/exportVideo.ts +39 -5
  15. package/src/commands/{still.ts → frame.ts} +23 -9
  16. package/src/commands/init.ts +1 -1
  17. package/src/commands/new.ts +1 -1
  18. package/src/cues.ts +34 -21
  19. package/src/discovery.ts +85 -5
  20. package/src/formats.ts +67 -8
  21. package/src/index.ts +1 -1
  22. package/src/jobs.ts +6 -2
  23. package/src/registry-snapshot.json +1530 -328
  24. package/src/registry-source.ts +87 -5
  25. package/src/render.ts +48 -13
  26. package/src/server.ts +55 -13
  27. package/studio/src/Studio.tsx +19 -22
  28. package/studio/src/components/ExportPanel.tsx +124 -90
  29. package/studio/src/components/Inspector.tsx +221 -0
  30. package/studio/src/components/Navigator.tsx +145 -0
  31. package/studio/src/components/Thumbnail.tsx +65 -23
  32. package/studio/src/components/ui.tsx +9 -2
  33. package/studio/src/lib/highlight.ts +85 -0
  34. package/studio/src/studio.css +435 -20
  35. package/studio/src/views/AssetsView.tsx +14 -1
  36. package/studio/src/views/BrandsView.tsx +74 -26
  37. package/studio/src/views/ComponentsView.tsx +206 -55
  38. package/studio/src/views/HomeView.tsx +44 -19
  39. package/studio/src/views/VideosView.tsx +53 -48
  40. package/studio/src/virtual.d.ts +4 -1
  41. package/dist/registry-snapshot-NIH2JMQ6.js +0 -3559
@@ -41,6 +41,46 @@
41
41
  }
42
42
  }
43
43
  },
44
+ {
45
+ "name": "accordion",
46
+ "description": "Sections opening one at a time, the outgoing one closing as the next grows.",
47
+ "registryDependencies": [],
48
+ "files": [
49
+ {
50
+ "path": "components/accordion/accordion.tsx",
51
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type AccordionSection = {title: string; body: string};\n\nexport type AccordionProps = {\n sections?: AccordionSection[];\n /** Which section opens, zero based. */\n opens?: number;\n /** Frame it starts opening. */\n openAt?: number;\n /** A second section to open, which closes the first. */\n thenOpens?: number;\n thenAt?: number;\n};\n\n/**\n * Sections opening, one at a time.\n *\n * Only one section is open at once and the outgoing one closes as the incoming\n * one grows, so the block's height barely moves. An accordion where two\n * sections are mid-animation in opposite directions is the readable version;\n * one where everything jumps to its final height is a cut.\n */\nexport const Accordion = ({\n sections = [\n {title: \"Is the preview the same as the export?\", body: \"Yes. Both render the same component at the same frame through the same runtime.\"},\n {title: \"Where do components come from?\", body: \"The registry, as source. odori add copies the directory into your project and you own it.\"},\n {title: \"Can I use my own fonts?\", body: \"Declare them on the brand. The runtime loads them before the first frame is drawn.\"},\n ],\n opens = 0,\n openAt = 20,\n thenOpens = 1,\n thenAt = 90,\n}: AccordionProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const openness = (index: number) => {\n const first = index === opens ? interpolate(frame, [openAt, openAt + 18], [0, 1], {easing: Easing.standard}) : 0;\n const closing = index === opens && thenOpens >= 0 ? interpolate(frame, [thenAt, thenAt + 18], [0, 1], {easing: Easing.standard}) : 0;\n const second = index === thenOpens ? interpolate(frame, [thenAt, thenAt + 18], [0, 1], {easing: Easing.standard}) : 0;\n return Math.max(0, Math.max(first - closing, second));\n };\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#09090B\", justifyContent: \"center\", padding: px(120)}}>\n <div style={{fontFamily: brand.typography.sans, maxWidth: px(1020), opacity: enter, width: \"100%\"}}>\n {sections.map((section, index) => {\n const open = openness(index);\n return (\n <div key={section.title} style={{borderBottom: `${px(1)}px solid #1F1F23`}}>\n <div\n style={{\n alignItems: \"center\",\n color: open > 0.4 ? \"#FAFAFA\" : \"#C8C8CE\",\n display: \"flex\",\n fontSize: px(27),\n gap: px(18),\n justifyContent: \"space-between\",\n padding: `${px(26)}px 0`,\n }}\n >\n {section.title}\n <span\n style={{\n color: \"#6E6E78\",\n display: \"inline-block\",\n fontSize: px(24),\n transform: `rotate(${open * 45}deg)`,\n }}\n >\n +\n </span>\n </div>\n <div\n style={{\n color: \"#8A8A93\",\n fontSize: px(22),\n lineHeight: 1.6,\n maxHeight: px(open * 120),\n opacity: open,\n overflow: \"hidden\",\n paddingBottom: px(open * 26),\n }}\n >\n {section.body}\n </div>\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
52
+ "target": "videos/components/accordion/accordion.tsx"
53
+ },
54
+ {
55
+ "path": "components/accordion/accordion.preview.tsx",
56
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Accordion} from \"./accordion\";\n\nexport default defineComponentPreview({\n title: \"Accordion\",\n category: \"Interface/Controls\",\n description: \"Sections opening one at a time, the outgoing one closing as the next grows.\",\n component: Accordion,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n opens: {type: \"number\", defaultValue: 0, min: 0, max: 4, step: 1},\n thenOpens: {type: \"number\", defaultValue: 1, min: -1, max: 4, step: 1},\n thenAt: {type: \"number\", defaultValue: 90, min: 40, max: 200, step: 5},\n },\n examples: [\n {name: \"Two beats\", props: {}},\n {name: \"One section\", props: {thenOpens: -1}},\n ],\n});\n",
57
+ "target": "videos/components/accordion/accordion.preview.tsx"
58
+ }
59
+ ],
60
+ "meta": {
61
+ "kind": "component",
62
+ "family": "Interface",
63
+ "namespaced": "@odori/accordion",
64
+ "contract": {
65
+ "aspectRatios": [
66
+ "16:9",
67
+ "1:1"
68
+ ],
69
+ "recommendedDurationInFrames": 180,
70
+ "minimumDurationInFrames": 75,
71
+ "entranceFrames": 14,
72
+ "exitFrames": 10,
73
+ "contentLimits": {},
74
+ "reducedMotion": "one section shown open",
75
+ "requires": {
76
+ "fonts": [
77
+ "sans"
78
+ ],
79
+ "audio": []
80
+ }
81
+ }
82
+ }
83
+ },
44
84
  {
45
85
  "name": "area-chart",
46
86
  "description": "Filled trend reveal for volume and cumulative change.",
@@ -83,6 +123,52 @@
83
123
  }
84
124
  }
85
125
  },
126
+ {
127
+ "name": "ascii-render",
128
+ "description": "A word measured cell by cell and redrawn in characters, assembling on a diagonal sweep.",
129
+ "registryDependencies": [],
130
+ "files": [
131
+ {
132
+ "path": "components/ascii-render/ascii-render.tsx",
133
+ "content": "import {Easing, Fill, interpolate, useCanvas, useBrand, useVideo} from \"odori\";\n\nexport type AsciiRenderProps = {\n /** The word rendered as characters. */\n text: string;\n /** The line under it, drawn as characters too. */\n subtitle?: string;\n /** Width of one cell in composition pixels. Smaller reads finer. */\n cell?: number;\n /** The ramp, darkest to brightest. */\n ramp?: string;\n /** Frames the fill takes to sweep across. */\n sweepFrames?: number;\n /** Ink colour. Defaults to the brand's foreground. */\n color?: string;\n};\n\n/**\n * A word resolved into characters, sampled rather than typed.\n *\n * The word is drawn to an offscreen canvas, its coverage is read cell by cell,\n * and each cell is replaced by the character that carries about that much ink.\n * That is why it holds its shape at any size and why the sweep can reveal it\n * by density rather than by wiping: what is on screen is a measurement of the\n * letterforms, not a picture of them.\n */\nexport const AsciiRender = ({\n text,\n subtitle,\n cell = 16,\n ramp = \" .:-=+*#%@\",\n sweepFrames = 40,\n color,\n}: AsciiRenderProps) => {\n const {width, height} = useVideo();\n const brand = useBrand();\n const ink = color ?? brand.colors.foreground;\n\n const canvas = useCanvas(\n (context, {frame, width: w, height: h}) => {\n context.clearRect(0, 0, w, h);\n\n const face = `${cell}px ui-monospace, \"SF Mono\", Menlo, monospace`;\n context.font = face;\n /*\n * A character is taller than it is wide, so the grid has to be too.\n * Sampling on square cells and drawing glyphs into them leaves a gap\n * beside every one - the picture reads as scattered dots rather than\n * text - and stretches the letterforms by the same ratio. Measuring the\n * advance and stepping by it puts the characters edge to edge, and\n * sampling the source on the same rectangle undoes the stretch.\n */\n const advance = context.measureText(\"M\").width || cell * 0.6;\n const columns = Math.max(8, Math.floor(w / advance));\n const rows = Math.max(6, Math.floor(h / cell));\n\n // The source: the same words, drawn once, to be measured.\n const source = document.createElement(\"canvas\");\n source.width = columns;\n source.height = rows;\n const scratch = source.getContext(\"2d\", {willReadFrequently: true});\n if (!scratch) return;\n\n scratch.fillStyle = \"#000000\";\n scratch.fillRect(0, 0, columns, rows);\n scratch.fillStyle = \"#ffffff\";\n scratch.textAlign = \"center\";\n scratch.textBaseline = \"middle\";\n\n /*\n * Sized against both budgets. The grid is now much wider than it is\n * tall, so a headline measured only against the columns overflows the\n * rows every time.\n */\n const wide = (columns * 0.92) / Math.max(3, text.length) * 1.7;\n const tall = rows * (subtitle ? 0.46 : 0.62);\n const headline = Math.max(6, Math.floor(Math.min(wide, tall)));\n scratch.font = `700 ${headline}px ${brand.typography.sans}`;\n scratch.fillText(text, columns / 2, rows / 2 - (subtitle ? headline * 0.34 : 0), columns * 0.92);\n if (subtitle) {\n // Big enough to resolve: under about five rows a word is unreadable\n // whatever the ramp does.\n const small = Math.max(5, Math.floor(headline * 0.3));\n scratch.font = `500 ${small}px ${brand.typography.sans}`;\n scratch.fillText(subtitle, columns / 2, rows / 2 + headline * 0.62, columns * 0.8);\n }\n\n const {data} = scratch.getImageData(0, 0, columns, rows);\n const sweep = interpolate(frame, [10, 10 + sweepFrames], [0, 1], {easing: Easing.standard});\n\n context.textBaseline = \"top\";\n context.fillStyle = ink;\n\n for (let row = 0; row < rows; row += 1) {\n for (let column = 0; column < columns; column += 1) {\n const luminance = data[(row * columns + column) * 4] / 255;\n if (luminance <= 0.02) continue;\n // The sweep runs diagonally, so the word assembles rather than\n // sliding: cells nearer the top left commit first.\n const progress = (column / columns) * 0.6 + (row / rows) * 0.4;\n const shown = interpolate(sweep, [progress - 0.18, progress], [0, 1], {easing: Easing.standard});\n if (shown <= 0.01) continue;\n\n const level = Math.min(ramp.length - 1, Math.floor(luminance * shown * (ramp.length - 1) + 0.5));\n const glyph = ramp[level];\n if (glyph === \" \") continue;\n context.globalAlpha = 0.35 + shown * 0.65;\n context.fillText(glyph, column * advance, row * cell);\n }\n }\n context.globalAlpha = 1;\n },\n [text, subtitle, cell, ramp, sweepFrames, ink, brand.typography.sans],\n );\n\n return (\n <Fill style={{alignItems: \"center\", background: brand.colors.background, justifyContent: \"center\"}}>\n <canvas ref={canvas} width={width} height={height} style={{height: \"100%\", width: \"100%\"}} />\n </Fill>\n );\n};\n",
134
+ "target": "videos/components/ascii-render/ascii-render.tsx"
135
+ },
136
+ {
137
+ "path": "components/ascii-render/ascii-render.preview.tsx",
138
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {AsciiRender} from \"./ascii-render\";\n\nexport default defineComponentPreview({\n title: \"ASCII render\",\n category: \"Media/Treatments\",\n description: \"A word measured cell by cell and redrawn in characters, assembling on a diagonal sweep.\",\n component: AsciiRender,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"ODORI\", maxLength: 16},\n subtitle: {type: \"text\", defaultValue: \"frame accurate\", maxLength: 32},\n cell: {type: \"number\", defaultValue: 16, min: 8, max: 40, step: 1},\n ramp: {type: \"text\", defaultValue: \" .:-=+*#%@\", maxLength: 24},\n sweepFrames: {type: \"number\", defaultValue: 40, min: 10, max: 120, step: 5},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Fine\", props: {cell: 10, text: \"SHIP IT\"}},\n {name: \"Blocky\", props: {cell: 28, ramp: \" ░▒▓█\", subtitle: \"\"}},\n ],\n});\n",
139
+ "target": "videos/components/ascii-render/ascii-render.preview.tsx"
140
+ }
141
+ ],
142
+ "meta": {
143
+ "kind": "component",
144
+ "family": "Media",
145
+ "namespaced": "@odori/ascii-render",
146
+ "contract": {
147
+ "aspectRatios": [
148
+ "16:9",
149
+ "9:16",
150
+ "1:1"
151
+ ],
152
+ "recommendedDurationInFrames": 180,
153
+ "minimumDurationInFrames": 75,
154
+ "entranceFrames": 12,
155
+ "exitFrames": 10,
156
+ "contentLimits": {
157
+ "text": 16,
158
+ "subtitle": 32,
159
+ "ramp": 24
160
+ },
161
+ "reducedMotion": "characters shown resolved, no sweep",
162
+ "requires": {
163
+ "fonts": [
164
+ "sans",
165
+ "mono"
166
+ ],
167
+ "audio": []
168
+ }
169
+ }
170
+ }
171
+ },
86
172
  {
87
173
  "name": "b-roll-window",
88
174
  "description": "Supporting footage framed beside the main narrative.",
@@ -95,13 +181,13 @@
95
181
  },
96
182
  {
97
183
  "path": "components/b-roll-window/b-roll-window.preview.tsx",
98
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {BRollWindow} from \"./b-roll-window\";\n\nconst Main = () => (\n <div\n style={{\n alignItems: \"center\",\n background: \"#080808\",\n color: \"#f5f5f5\",\n display: \"flex\",\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 82,\n inset: 0,\n justifyContent: \"center\",\n letterSpacing: \"-0.04em\",\n position: \"absolute\",\n }}\n >\n The main narrative\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"B-roll window\",\n category: \"Media and canvas\",\n description: \"Supporting footage framed beside the main narrative.\",\n component: BRollWindow,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n corner: {\n type: \"select\",\n defaultValue: \"bottom-right\",\n options: [\"bottom-right\", \"bottom-left\", \"top-right\", \"top-left\"],\n },\n at: {type: \"number\", defaultValue: 24, min: 0, max: 90},\n until: {type: \"number\", defaultValue: 140, min: 30, max: 200},\n label: {type: \"text\", defaultValue: \"odori export launch\"},\n },\n examples: [\n {name: \"Default\", props: {children: <Main />}},\n {name: \"Top left\", props: {children: <Main />, corner: \"top-left\"}},\n ],\n});\n",
184
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {BRollWindow} from \"./b-roll-window\";\n\nconst Main = () => (\n <div\n style={{\n alignItems: \"center\",\n background: \"#080808\",\n color: \"#f5f5f5\",\n display: \"flex\",\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 82,\n inset: 0,\n justifyContent: \"center\",\n letterSpacing: \"-0.04em\",\n position: \"absolute\",\n }}\n >\n The main narrative\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"B-roll window\",\n category: \"Media/Footage\",\n description: \"Supporting footage framed beside the main narrative.\",\n component: BRollWindow,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n corner: {\n type: \"select\",\n defaultValue: \"bottom-right\",\n options: [\"bottom-right\", \"bottom-left\", \"top-right\", \"top-left\"],\n },\n at: {type: \"number\", defaultValue: 24, min: 0, max: 90},\n until: {type: \"number\", defaultValue: 140, min: 30, max: 200},\n label: {type: \"text\", defaultValue: \"odori export launch\"},\n },\n examples: [\n {name: \"Default\", props: {children: <Main />}},\n {name: \"Top left\", props: {children: <Main />, corner: \"top-left\"}},\n ],\n});\n",
99
185
  "target": "videos/components/b-roll-window/b-roll-window.preview.tsx"
100
186
  }
101
187
  ],
102
188
  "meta": {
103
189
  "kind": "component",
104
- "family": "Media and canvas",
190
+ "family": "Media",
105
191
  "namespaced": "@odori/b-roll-window",
106
192
  "contract": {
107
193
  "aspectRatios": [
@@ -171,33 +257,28 @@
171
257
  }
172
258
  },
173
259
  {
174
- "name": "bed-build",
175
- "description": "A rising bed that lands on the reveal it is building to.",
260
+ "name": "bed-energetic",
261
+ "description": "Loud and rhythmic, for a launch. Mixed hot, so give it a lower gain.",
176
262
  "registryDependencies": [],
177
263
  "files": [
178
264
  {
179
- "path": "components/bed-build/bed-build.tsx",
180
- "content": "import {\n chord,\n defineCue,\n gain,\n highPass,\n lowPass,\n mix,\n noise,\n normalize,\n note,\n pad,\n sequence,\n shape,\n sine,\n sweep,\n type CueDefinition,\n} from \"odori\";\n\nexport type BedBuildOptions = {\n /** Root of the chord underneath, as a note name. */\n root?: string;\n /** Seconds the build runs before it lands. */\n seconds?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * A bed that goes somewhere: a chord that opens up, a noise sweep rising\n * through it, and a tick pattern that doubles halfway. For a reveal, where the\n * sound has to arrive at the same moment the picture does.\n *\n * It does not loop. A build that repeats is a build that never landed, so this\n * one is placed once, against the thing it is building to.\n */\nexport const bedBuild = ({root = \"d2\", seconds = 4, peak = 0.7}: BedBuildOptions = {}): CueDefinition => {\n const samples = Math.round(48000 * seconds);\n const bpm = 120;\n\n return defineCue({\n name: \"bed.build\",\n durationInFrames: Math.round(seconds * 30),\n params: {root, seconds, peak},\n render: () =>\n normalize(\n mix(\n // The chord, opening as the filter does.\n gain(lowPass(pad(chord(root, \"minor\"), samples, {envelope: {attack: 1.2, sustain: 1}}), 1200), 0.8),\n // The riser: filtered noise climbing, plus a tone sweeping under it.\n gain(\n shape(highPass(noise(samples, 11), 700), {attack: seconds * 0.92, sustain: 1, release: 0.08}),\n 0.22,\n ),\n gain(shape(sweep(samples, note(root), note(root) * 4), {attack: seconds * 0.8, sustain: 1}), 0.3),\n // Ticks that double in the second half, which is what makes a build\n // feel like it is accelerating without changing tempo.\n sequence(\n [\n ...Array.from({length: 8}, (_, index) => ({at: index, gain: 0.18 + index * 0.02})),\n ...Array.from({length: 16}, (_, index) => ({at: 8 + index * 0.5, gain: 0.2 + index * 0.02})),\n ].map(({at, gain: level}) => ({\n at,\n gain: level,\n play: (length: number) => shape(sine(length, note(root) * 8), {attack: 0.002, decay: 0.06, sustain: 0}),\n })),\n {bpm, samples},\n ),\n ),\n peak,\n ),\n });\n};\n",
181
- "target": "videos/components/bed-build/bed-build.tsx"
182
- },
183
- {
184
- "path": "components/bed-build/bed-build.preview.tsx",
185
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {bedBuild, type BedBuildOptions} from \"./bed-build\";\n\nconst Wave = ({root, seconds, peak}: BedBuildOptions) => <CueWave cue={bedBuild({root, seconds, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Build bed\",\n category: \"Sound\",\n description: \"A rising bed that lands on the reveal it is building to.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n root: {type: \"select\", defaultValue: \"d2\", options: [\"c2\", \"d2\", \"f2\", \"g2\"]},\n seconds: {type: \"number\", defaultValue: 4, min: 2, max: 8},\n peak: {type: \"number\", defaultValue: 0.7, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Short\", props: {seconds: 2}},\n ],\n});\n",
186
- "target": "videos/components/bed-build/bed-build.preview.tsx"
265
+ "path": "components/bed-energetic/bed-energetic.preview.tsx",
266
+ "content": "import {SampleWave, defineComponentPreview} from \"odori/preview\";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [\n 0.65, 0.93, 1, 0.94, 0.93, 1, 0.96, 0.93, 1, 0.95, 1, 0.51, 0.97, 1, 0.95, 0.84, 1, 0.94, 1, 0.96, 0.95, 1,\n 0.97, 0.94, 1, 1, 0.66, 1, 0.96, 1, 1, 0.94, 0.91, 0.92, 0.96, 1, 0.96, 1, 0.99, 0.99, 1, 0.95, 1, 0.98, 0.92,\n 0.89, 1, 0.9, 0.99, 0.97, 0.88, 1, 0.91, 1, 0.99, 0.94, 1, 0.95, 0.97, 0.97, 1, 0.98, 1, 0.91, 0.99, 0.94, 0.94,\n 0.99, 0.99, 0.97, 0.97, 0.96, 0.9, 1, 0.67, 1, 0.94, 1, 1, 0.95, 0.88, 1, 0.96, 0.99, 1, 0.98, 1, 0.7, 0.96, 1,\n 0.96, 0.81, 1, 0.95, 1, 0.7, 0.96, 0.94, 0.94, 0.85, 1, 0.96, 1, 0.94, 0.98, 0.98, 0.91, 0.96, 1, 0.96, 0.94, 1,\n 0.97, 1, 0.96, 0.94, 1, 0.81, 0.55, 0.34,\n];\n\nexport default defineComponentPreview({\n title: \"Energetic bed\",\n category: \"Sound/Beds\",\n description: \"Loud and rhythmic, for a launch. Mixed hot, so give it a lower gain.\",\n component: () => <SampleWave peaks={PEAKS} label=\"bed.energetic\" seconds={45} />,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n examples: [{name: \"Default\", props: {}}],\n});\n",
267
+ "target": "videos/components/bed-energetic/bed-energetic.preview.tsx"
187
268
  }
188
269
  ],
189
270
  "meta": {
190
- "kind": "cue",
271
+ "kind": "asset",
191
272
  "family": "Sound",
192
- "namespaced": "@odori/bed-build",
273
+ "namespaced": "@odori/bed-energetic",
193
274
  "contract": {
194
275
  "aspectRatios": [
195
276
  "16:9",
196
277
  "9:16",
197
278
  "1:1"
198
279
  ],
199
- "recommendedDurationInFrames": 120,
200
- "minimumDurationInFrames": 120,
280
+ "recommendedDurationInFrames": 1350,
281
+ "minimumDurationInFrames": 30,
201
282
  "entranceFrames": 0,
202
283
  "exitFrames": 0,
203
284
  "contentLimits": {},
@@ -209,41 +290,37 @@
209
290
  "audio": []
210
291
  },
211
292
  "loops": false
212
- },
213
- "cue": {
214
- "name": "bed.build",
215
- "export": "bedBuild"
216
293
  }
217
294
  }
218
295
  },
219
296
  {
220
- "name": "bed-drift",
221
- "description": "Sustained pads with no rhythm, for slow product shots.",
297
+ "name": "bed-pulse",
298
+ "description": "A soft repeating pulse over a held chord, for narration.",
222
299
  "registryDependencies": [],
223
300
  "files": [
224
301
  {
225
- "path": "components/bed-drift/bed-drift.tsx",
226
- "content": "import {\n chord,\n defineCue,\n gain,\n lowPass,\n mix,\n normalize,\n pad,\n type CueDefinition,\n} from \"odori\";\n\nexport type BedDriftOptions = {\n /** Root of the chord the pad holds, as a note name. */\n root?: string;\n /** Detune between voices, in cents. More beats faster. */\n detuneCents?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Sustained pads and nothing else: no rhythm, no onset to count against the\n * cut. For slow product shots, where a pulse would impose a tempo the picture\n * is not keeping.\n *\n * Two stacked voicings a fifth apart beat slowly against each other, which is\n * what stops a held chord from sounding like a test tone. Eight seconds, and\n * it loops.\n */\nexport const bedDrift = ({root = \"f2\", detuneCents = 7, peak = 0.5}: BedDriftOptions = {}): CueDefinition => {\n const samples = 48000 * 8;\n\n return defineCue({\n name: \"bed.drift\",\n durationInFrames: 240,\n loops: true,\n params: {root, detuneCents, peak},\n render: () =>\n normalize(\n lowPass(\n mix(\n pad(chord(root, \"add9\"), samples, {\n detuneCents,\n envelope: {attack: 2.4, sustain: 1, release: 2.6},\n }),\n // The upper voicing enters later and leaves earlier, so the two\n // never turn at the same moment and the pad never sounds gated.\n gain(\n pad(chord(root, \"sus2\").map((frequency) => frequency * 2), samples, {\n detuneCents: detuneCents * 1.5,\n envelope: {attack: 3.6, sustain: 1, release: 1.8},\n }),\n 0.55,\n ),\n ),\n 1400,\n ),\n peak,\n ),\n });\n};\n",
227
- "target": "videos/components/bed-drift/bed-drift.tsx"
302
+ "path": "components/bed-pulse/bed-pulse.tsx",
303
+ "content": "import {\n chord,\n defineCue,\n gain,\n lowPass,\n mix,\n normalize,\n note,\n pad,\n sequence,\n shape,\n sine,\n step,\n type CueDefinition,\n} from \"odori\";\n\nexport type BedPulseOptions = {\n /** Tempo of the pulse. Slower reads calmer under narration. */\n bpm?: number;\n /** Root of the held chord, as a note name. */\n root?: string;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Two bars of soft repeating pulse: a held minor seventh underneath, and a\n * muted low tone on each beat to give the bed a heartbeat without giving it a\n * groove. Written to sit under a voice, so nothing in it competes for the\n * frequencies a voice uses.\n *\n * It loops, so the placement repeats it for as long as the scene runs and the\n * score is rendered once.\n */\nexport const bedPulse = ({bpm = 84, root = \"c2\", peak = 0.55}: BedPulseOptions = {}): CueDefinition => {\n const beat = step(bpm, 4);\n const bars = 2;\n const samples = beat * 4 * bars;\n\n return defineCue({\n name: \"bed.pulse\",\n // Two bars at this tempo, in frames at 30fps.\n durationInFrames: Math.round((samples / 48000) * 30),\n loops: true,\n params: {bpm, root, peak},\n render: () =>\n normalize(\n mix(\n // The held chord, low passed so it stays under everything else.\n gain(lowPass(pad(chord(root, \"minor7\"), samples, {envelope: {attack: 0.9, sustain: 1, release: 0.9}}), 900), 0.9),\n // The pulse itself: one soft tone per beat, decaying immediately.\n sequence(\n Array.from({length: 4 * bars}, (_, index) => ({\n at: index,\n play: (length: number) =>\n shape(sine(length, note(root) * 2), {attack: 0.01, decay: 0.35, sustain: 0, release: 0.1}),\n // The downbeat is louder, which is what makes four beats read as a bar.\n gain: index % 4 === 0 ? 0.5 : 0.28,\n })),\n {bpm, samples},\n ),\n ),\n peak,\n ),\n });\n};\n",
304
+ "target": "videos/components/bed-pulse/bed-pulse.tsx"
228
305
  },
229
306
  {
230
- "path": "components/bed-drift/bed-drift.preview.tsx",
231
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {bedDrift, type BedDriftOptions} from \"./bed-drift\";\n\nconst Wave = ({root, detuneCents, peak}: BedDriftOptions) => <CueWave cue={bedDrift({root, detuneCents, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Drift bed\",\n category: \"Sound\",\n description: \"Sustained pads with no rhythm, for slow product shots.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n root: {type: \"select\", defaultValue: \"f2\", options: [\"c2\", \"d2\", \"f2\", \"a1\"]},\n detuneCents: {type: \"number\", defaultValue: 7, min: 0, max: 24},\n peak: {type: \"number\", defaultValue: 0.5, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Wider\", props: {detuneCents: 16}},\n ],\n});\n",
232
- "target": "videos/components/bed-drift/bed-drift.preview.tsx"
307
+ "path": "components/bed-pulse/bed-pulse.preview.tsx",
308
+ "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {bedPulse, type BedPulseOptions} from \"./bed-pulse\";\n\nconst Wave = ({bpm, root, peak}: BedPulseOptions) => <CueWave cue={bedPulse({bpm, root, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Pulse bed\",\n category: \"Sound/Beds\",\n description: \"A soft repeating pulse over a held chord, for narration.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n bpm: {type: \"number\", defaultValue: 84, min: 60, max: 140},\n root: {type: \"select\", defaultValue: \"c2\", options: [\"a1\", \"c2\", \"d2\", \"f2\"]},\n peak: {type: \"number\", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Slower\", props: {bpm: 68}},\n {name: \"Darker\", props: {root: \"a1\"}},\n ],\n});\n",
309
+ "target": "videos/components/bed-pulse/bed-pulse.preview.tsx"
233
310
  }
234
311
  ],
235
312
  "meta": {
236
313
  "kind": "cue",
237
314
  "family": "Sound",
238
- "namespaced": "@odori/bed-drift",
315
+ "namespaced": "@odori/bed-pulse",
239
316
  "contract": {
240
317
  "aspectRatios": [
241
318
  "16:9",
242
319
  "9:16",
243
320
  "1:1"
244
321
  ],
245
- "recommendedDurationInFrames": 240,
246
- "minimumDurationInFrames": 240,
322
+ "recommendedDurationInFrames": 171,
323
+ "minimumDurationInFrames": 171,
247
324
  "entranceFrames": 0,
248
325
  "exitFrames": 0,
249
326
  "contentLimits": {},
@@ -257,39 +334,34 @@
257
334
  "loops": true
258
335
  },
259
336
  "cue": {
260
- "name": "bed.drift",
261
- "export": "bedDrift"
337
+ "name": "bed.pulse",
338
+ "export": "bedPulse"
262
339
  }
263
340
  }
264
341
  },
265
342
  {
266
- "name": "bed-pulse",
267
- "description": "A soft repeating pulse over a held chord, for narration.",
343
+ "name": "bed-soft-pulse",
344
+ "description": "A pulse without a beat, for a cut that needs momentum but not tempo.",
268
345
  "registryDependencies": [],
269
346
  "files": [
270
347
  {
271
- "path": "components/bed-pulse/bed-pulse.tsx",
272
- "content": "import {\n chord,\n defineCue,\n gain,\n lowPass,\n mix,\n normalize,\n note,\n pad,\n sequence,\n shape,\n sine,\n step,\n type CueDefinition,\n} from \"odori\";\n\nexport type BedPulseOptions = {\n /** Tempo of the pulse. Slower reads calmer under narration. */\n bpm?: number;\n /** Root of the held chord, as a note name. */\n root?: string;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Two bars of soft repeating pulse: a held minor seventh underneath, and a\n * muted low tone on each beat to give the bed a heartbeat without giving it a\n * groove. Written to sit under a voice, so nothing in it competes for the\n * frequencies a voice uses.\n *\n * It loops, so the placement repeats it for as long as the scene runs and the\n * score is rendered once.\n */\nexport const bedPulse = ({bpm = 84, root = \"c2\", peak = 0.55}: BedPulseOptions = {}): CueDefinition => {\n const beat = step(bpm, 4);\n const bars = 2;\n const samples = beat * 4 * bars;\n\n return defineCue({\n name: \"bed.pulse\",\n // Two bars at this tempo, in frames at 30fps.\n durationInFrames: Math.round((samples / 48000) * 30),\n loops: true,\n params: {bpm, root, peak},\n render: () =>\n normalize(\n mix(\n // The held chord, low passed so it stays under everything else.\n gain(lowPass(pad(chord(root, \"minor7\"), samples, {envelope: {attack: 0.9, sustain: 1, release: 0.9}}), 900), 0.9),\n // The pulse itself: one soft tone per beat, decaying immediately.\n sequence(\n Array.from({length: 4 * bars}, (_, index) => ({\n at: index,\n play: (length: number) =>\n shape(sine(length, note(root) * 2), {attack: 0.01, decay: 0.35, sustain: 0, release: 0.1}),\n // The downbeat is louder, which is what makes four beats read as a bar.\n gain: index % 4 === 0 ? 0.5 : 0.28,\n })),\n {bpm, samples},\n ),\n ),\n peak,\n ),\n });\n};\n",
273
- "target": "videos/components/bed-pulse/bed-pulse.tsx"
274
- },
275
- {
276
- "path": "components/bed-pulse/bed-pulse.preview.tsx",
277
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {bedPulse, type BedPulseOptions} from \"./bed-pulse\";\n\nconst Wave = ({bpm, root, peak}: BedPulseOptions) => <CueWave cue={bedPulse({bpm, root, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Pulse bed\",\n category: \"Sound\",\n description: \"A soft repeating pulse over a held chord, for narration.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n bpm: {type: \"number\", defaultValue: 84, min: 60, max: 140},\n root: {type: \"select\", defaultValue: \"c2\", options: [\"a1\", \"c2\", \"d2\", \"f2\"]},\n peak: {type: \"number\", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Slower\", props: {bpm: 68}},\n {name: \"Darker\", props: {root: \"a1\"}},\n ],\n});\n",
278
- "target": "videos/components/bed-pulse/bed-pulse.preview.tsx"
348
+ "path": "components/bed-soft-pulse/bed-soft-pulse.preview.tsx",
349
+ "content": "import {SampleWave, defineComponentPreview} from \"odori/preview\";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [\n 0.05, 0.07, 0.16, 0.17, 0.27, 0.38, 0.15, 0.26, 0.21, 0.31, 0.33, 0.21, 0.38, 0.3, 0.32, 0.36, 0.25, 0.3, 0.24,\n 0.2, 0.26, 0.12, 0.26, 0.21, 0.19, 0.31, 0.22, 0.35, 0.29, 0.22, 0.26, 0.2, 0.33, 0.22, 0.1, 0.24, 0.17, 0.34,\n 0.24, 0.22, 0.28, 0.26, 0.44, 0.33, 0.26, 0.33, 0.22, 0.35, 0.26, 0.18, 0.24, 0.23, 0.33, 0.29, 0.32, 0.32,\n 0.28, 0.37, 0.3, 0.27, 0.34, 0.15, 0.26, 0.17, 0.16, 0.29, 0.22, 0.3, 0.2, 0.2, 0.33, 0.24, 0.32, 0.22, 0.17,\n 0.31, 0.2, 0.35, 0.22, 0.13, 0.25, 0.13, 0.28, 0.23, 0.21, 0.33, 0.24, 0.32, 0.3, 0.29, 0.37, 0.24, 0.33, 0.29,\n 0.27, 0.36, 0.32, 0.33, 0.24, 0.21, 0.33, 0.28, 0.23, 0.13, 0.13, 0.27, 0.25, 0.27, 0.25, 0.2, 0.35, 0.28, 0.3,\n 0.18, 0.19, 0.25, 0.14, 0.15, 0.06, 0.02,\n];\n\nexport default defineComponentPreview({\n title: \"Soft pulse bed\",\n category: \"Sound/Beds\",\n description: \"A pulse without a beat, for a cut that needs momentum but not tempo.\",\n component: () => <SampleWave peaks={PEAKS} label=\"bed.soft\" seconds={48} />,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n examples: [{name: \"Default\", props: {}}],\n});\n",
350
+ "target": "videos/components/bed-soft-pulse/bed-soft-pulse.preview.tsx"
279
351
  }
280
352
  ],
281
353
  "meta": {
282
- "kind": "cue",
354
+ "kind": "asset",
283
355
  "family": "Sound",
284
- "namespaced": "@odori/bed-pulse",
356
+ "namespaced": "@odori/bed-soft-pulse",
285
357
  "contract": {
286
358
  "aspectRatios": [
287
359
  "16:9",
288
360
  "9:16",
289
361
  "1:1"
290
362
  ],
291
- "recommendedDurationInFrames": 171,
292
- "minimumDurationInFrames": 171,
363
+ "recommendedDurationInFrames": 1440,
364
+ "minimumDurationInFrames": 30,
293
365
  "entranceFrames": 0,
294
366
  "exitFrames": 0,
295
367
  "contentLimits": {},
@@ -300,42 +372,33 @@
300
372
  ],
301
373
  "audio": []
302
374
  },
303
- "loops": true
304
- },
305
- "cue": {
306
- "name": "bed.pulse",
307
- "export": "bedPulse"
375
+ "loops": false
308
376
  }
309
377
  }
310
378
  },
311
379
  {
312
- "name": "bed-tick",
313
- "description": "Minimal rhythm for changelogs and lists.",
380
+ "name": "bed-warm-minimal",
381
+ "description": "Unhurried and low contrast. The default bed under narration.",
314
382
  "registryDependencies": [],
315
383
  "files": [
316
384
  {
317
- "path": "components/bed-tick/bed-tick.tsx",
318
- "content": "import {\n defineCue,\n gain,\n highPass,\n lowPass,\n mix,\n noise,\n normalize,\n note,\n sequence,\n shape,\n sine,\n step,\n triangle,\n type CueDefinition,\n} from \"odori\";\n\nexport type BedTickOptions = {\n /** Tempo of the pattern. */\n bpm?: number;\n /** Root of the bass note, as a note name. */\n root?: string;\n /** Shuffle on the offbeats, 0 to 0.3. */\n swing?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Minimal rhythm: a closed hat on every eighth, a soft kick on one and three,\n * and a bass note under the bar. For changelogs and lists, where the cut moves\n * item to item and the sound should keep time with it.\n *\n * One bar, looping. Everything is pitched away from the middle of the spectrum\n * so a voice or a UI sound still has room.\n */\nexport const bedTick = ({bpm = 104, root = \"e2\", swing = 0.08, peak = 0.6}: BedTickOptions = {}): CueDefinition => {\n const beat = step(bpm, 4);\n const samples = beat * 4;\n\n const hat = (length: number) =>\n shape(highPass(noise(length, 3), 6000), {attack: 0.001, decay: 0.045, sustain: 0});\n const kick = (length: number) =>\n shape(lowPass(sine(length, note(root) / 2), 220), {attack: 0.004, decay: 0.22, sustain: 0});\n\n return defineCue({\n name: \"bed.tick\",\n durationInFrames: Math.round((samples / 48000) * 30),\n loops: true,\n params: {bpm, root, swing, peak},\n render: () =>\n normalize(\n mix(\n sequence(\n Array.from({length: 8}, (_, index) => ({at: index, play: hat, gain: index % 2 === 0 ? 0.3 : 0.16})),\n {bpm, division: 8, swing, samples},\n ),\n sequence([{at: 0, play: kick, gain: 0.7}, {at: 2, play: kick, gain: 0.5}], {bpm, samples}),\n // The bass holds the bar together under both.\n gain(shape(triangle(samples, note(root)), {attack: 0.05, sustain: 0.8, release: 0.4}), 0.18),\n ),\n peak,\n ),\n });\n};\n",
319
- "target": "videos/components/bed-tick/bed-tick.tsx"
320
- },
321
- {
322
- "path": "components/bed-tick/bed-tick.preview.tsx",
323
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {bedTick, type BedTickOptions} from \"./bed-tick\";\n\nconst Wave = ({bpm, root, swing, peak}: BedTickOptions) => <CueWave cue={bedTick({bpm, root, swing, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Tick bed\",\n category: \"Sound\",\n description: \"Minimal rhythm for changelogs and lists.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n bpm: {type: \"number\", defaultValue: 104, min: 70, max: 160},\n root: {type: \"select\", defaultValue: \"e2\", options: [\"c2\", \"d2\", \"e2\", \"g2\"]},\n swing: {type: \"number\", defaultValue: 0.08, min: 0, max: 0.3, step: 0.02},\n peak: {type: \"number\", defaultValue: 0.6, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Straight\", props: {swing: 0}},\n ],\n});\n",
324
- "target": "videos/components/bed-tick/bed-tick.preview.tsx"
385
+ "path": "components/bed-warm-minimal/bed-warm-minimal.preview.tsx",
386
+ "content": "import {SampleWave, defineComponentPreview} from \"odori/preview\";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [\n 0.05, 0.1, 0.13, 0.24, 0.29, 0.27, 0.17, 0.17, 0.21, 0.3, 0.22, 0.29, 0.27, 0.33, 0.33, 0.29, 0.24, 0.21, 0.25,\n 0.2, 0.2, 0.17, 0.2, 0.19, 0.24, 0.2, 0.31, 0.22, 0.34, 0.21, 0.23, 0.28, 0.24, 0.22, 0.14, 0.23, 0.18, 0.26,\n 0.2, 0.27, 0.22, 0.32, 0.33, 0.34, 0.24, 0.24, 0.28, 0.25, 0.25, 0.23, 0.21, 0.23, 0.27, 0.27, 0.36, 0.24, 0.32,\n 0.3, 0.35, 0.3, 0.28, 0.2, 0.16, 0.2, 0.17, 0.29, 0.24, 0.2, 0.16, 0.25, 0.24, 0.31, 0.25, 0.26, 0.18, 0.18,\n 0.28, 0.25, 0.2, 0.17, 0.23, 0.15, 0.21, 0.23, 0.24, 0.21, 0.22, 0.24, 0.36, 0.29, 0.29, 0.33, 0.31, 0.32, 0.28,\n 0.45, 0.38, 0.27, 0.19, 0.31, 0.26, 0.34, 0.16, 0.2, 0.13, 0.24, 0.27, 0.24, 0.27, 0.21, 0.3, 0.29, 0.27, 0.21,\n 0.19, 0.16, 0.17, 0.09, 0.08, 0.02,\n];\n\nexport default defineComponentPreview({\n title: \"Warm minimal bed\",\n category: \"Sound/Beds\",\n description: \"Unhurried and low contrast. The default bed under narration.\",\n component: () => <SampleWave peaks={PEAKS} label=\"bed.warm\" seconds={48} />,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n examples: [{name: \"Default\", props: {}}],\n});\n",
387
+ "target": "videos/components/bed-warm-minimal/bed-warm-minimal.preview.tsx"
325
388
  }
326
389
  ],
327
390
  "meta": {
328
- "kind": "cue",
391
+ "kind": "asset",
329
392
  "family": "Sound",
330
- "namespaced": "@odori/bed-tick",
393
+ "namespaced": "@odori/bed-warm-minimal",
331
394
  "contract": {
332
395
  "aspectRatios": [
333
396
  "16:9",
334
397
  "9:16",
335
398
  "1:1"
336
399
  ],
337
- "recommendedDurationInFrames": 69,
338
- "minimumDurationInFrames": 69,
400
+ "recommendedDurationInFrames": 1440,
401
+ "minimumDurationInFrames": 30,
339
402
  "entranceFrames": 0,
340
403
  "exitFrames": 0,
341
404
  "contentLimits": {},
@@ -346,11 +409,7 @@
346
409
  ],
347
410
  "audio": []
348
411
  },
349
- "loops": true
350
- },
351
- "cue": {
352
- "name": "bed.tick",
353
- "export": "bedTick"
412
+ "loops": false
354
413
  }
355
414
  }
356
415
  },
@@ -400,7 +459,7 @@
400
459
  },
401
460
  {
402
461
  "name": "brand-provider",
403
- "description": "Typed color, type, spacing, radius, and motion tokens.",
462
+ "description": "The resolved brand rendered as a sheet: color, type, and motion.",
404
463
  "registryDependencies": [],
405
464
  "files": [
406
465
  {
@@ -410,13 +469,13 @@
410
469
  },
411
470
  {
412
471
  "path": "components/brand-provider/brand-provider.preview.tsx",
413
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {BrandProvider} from \"./brand-provider\";\n\nexport default defineComponentPreview({\n title: \"Brand provider\",\n category: \"Foundation\",\n description: \"Typed color, type, spacing, radius, and motion tokens.\",\n component: BrandProvider,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Brand tokens\"},\n },\n examples: [\n {name: \"Everything\", props: {}},\n {name: \"Colors only\", props: {show: [\"colors\"], title: \"Palette\"}},\n ],\n});\n",
472
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {BrandProvider} from \"./brand-provider\";\n\nexport default defineComponentPreview({\n title: \"Brand provider\",\n category: \"Brand\",\n description: \"The resolved brand rendered as a sheet: color, type, and motion.\",\n component: BrandProvider,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Brand tokens\"},\n },\n examples: [\n {name: \"Everything\", props: {}},\n {name: \"Colors only\", props: {show: [\"colors\"], title: \"Palette\"}},\n ],\n});\n",
414
473
  "target": "videos/components/brand-provider/brand-provider.preview.tsx"
415
474
  }
416
475
  ],
417
476
  "meta": {
418
477
  "kind": "component",
419
- "family": "Foundation",
478
+ "family": "Brand",
420
479
  "namespaced": "@odori/brand-provider",
421
480
  "contract": {
422
481
  "aspectRatios": [
@@ -440,52 +499,6 @@
440
499
  }
441
500
  }
442
501
  },
443
- {
444
- "name": "brand-resolve",
445
- "description": "The closing sting: a fifth, a root, and a sub under the landing.",
446
- "registryDependencies": [],
447
- "files": [
448
- {
449
- "path": "components/brand-resolve/brand-resolve.tsx",
450
- "content": "import {\n defineCue,\n mix,\n normalize,\n shape,\n sine,\n type CueDefinition,\n} from \"odori\";\n\nexport type BrandResolveOptions = {\n /** The note the sting lands on, in hertz. */\n root?: number;\n /** Sub weight under the landing, 0 to 1. */\n weight?: number;\n peak?: number;\n};\n\n/**\n * The closing sting: a fifth above the root arriving first, the root landing\n * under it, and a sub giving the landing weight. Thirty six frames, so an end\n * card can breathe before the cut.\n */\nexport const brandResolve = ({root = 196, weight = 0.5, peak = 0.8}: BrandResolveOptions = {}): CueDefinition =>\n defineCue({\n name: \"brand.resolve\",\n durationInFrames: 36,\n params: {root, weight, peak},\n render: ({samples}) =>\n normalize(\n mix(\n shape(sine(samples, root * 1.5), {attack: 0.01, decay: 0.3, sustain: 0.18, release: 0.5}),\n shape(sine(samples, root), {attack: 0.04, decay: 0.35, sustain: 0.4, release: 0.6}),\n shape(sine(samples, root / 2), {attack: 0.02, decay: 0.45, sustain: weight * 0.6, release: 0.5}),\n ),\n peak,\n ),\n });\n",
451
- "target": "videos/components/brand-resolve/brand-resolve.tsx"
452
- },
453
- {
454
- "path": "components/brand-resolve/brand-resolve.preview.tsx",
455
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {brandResolve, type BrandResolveOptions} from \"./brand-resolve\";\n\nconst Wave = ({root, weight, peak}: BrandResolveOptions) => <CueWave cue={brandResolve({root, weight, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Brand resolve\",\n category: \"Sound\",\n description: \"The closing sting: a fifth, a root, and a sub under the landing.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n root: {type: \"number\", defaultValue: 196, min: 80, max: 400},\n weight: {type: \"number\", defaultValue: 0.5, min: 0, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Lighter\", props: {root: 262, weight: 0.2}},\n ],\n});\n",
456
- "target": "videos/components/brand-resolve/brand-resolve.preview.tsx"
457
- }
458
- ],
459
- "meta": {
460
- "kind": "cue",
461
- "family": "Sound",
462
- "namespaced": "@odori/brand-resolve",
463
- "contract": {
464
- "aspectRatios": [
465
- "16:9",
466
- "9:16",
467
- "1:1"
468
- ],
469
- "recommendedDurationInFrames": 36,
470
- "minimumDurationInFrames": 36,
471
- "entranceFrames": 0,
472
- "exitFrames": 0,
473
- "contentLimits": {},
474
- "reducedMotion": "the waveform renders without a playhead",
475
- "requires": {
476
- "fonts": [
477
- "mono"
478
- ],
479
- "audio": []
480
- },
481
- "loops": false
482
- },
483
- "cue": {
484
- "name": "brand.resolve",
485
- "export": "brandResolve"
486
- }
487
- }
488
- },
489
502
  {
490
503
  "name": "browser-demo",
491
504
  "description": "Browser chrome for deterministic product interface frames.",
@@ -498,13 +511,13 @@
498
511
  },
499
512
  {
500
513
  "path": "components/browser-demo/browser-demo.preview.tsx",
501
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {BrowserDemo} from \"./browser-demo\";\n\nexport default defineComponentPreview({\n title: \"Browser demo\",\n category: \"Product UI\",\n description: \"Browser chrome for deterministic product interface frames.\",\n component: BrowserDemo,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {url: {type: \"text\", defaultValue: \"odori.dev/docs\"}},\n examples: [\n {name: \"Empty frame\", props: {url: \"odori.dev/docs\"}},\n {\n name: \"With cursor\",\n props: {\n url: \"odori.dev/components\",\n cursor: [\n {frame: 0, x: 200, y: 200},\n {frame: 60, x: 900, y: 520},\n ],\n },\n },\n ],\n});\n",
514
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {BrowserDemo} from \"./browser-demo\";\n\nexport default defineComponentPreview({\n title: \"Browser demo\",\n category: \"Interface/Chrome\",\n description: \"Browser chrome for deterministic product interface frames.\",\n component: BrowserDemo,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {url: {type: \"text\", defaultValue: \"odori.dev/docs\"}},\n examples: [\n {name: \"Empty frame\", props: {url: \"odori.dev/docs\"}},\n {\n name: \"With cursor\",\n props: {\n url: \"odori.dev/components\",\n cursor: [\n {frame: 0, x: 200, y: 200},\n {frame: 60, x: 900, y: 520},\n ],\n },\n },\n ],\n});\n",
502
515
  "target": "videos/components/browser-demo/browser-demo.preview.tsx"
503
516
  }
504
517
  ],
505
518
  "meta": {
506
519
  "kind": "component",
507
- "family": "Product UI",
520
+ "family": "Interface",
508
521
  "namespaced": "@odori/browser-demo",
509
522
  "contract": {
510
523
  "aspectRatios": [
@@ -528,6 +541,48 @@
528
541
  }
529
542
  }
530
543
  },
544
+ {
545
+ "name": "camera-stage",
546
+ "description": "One world with everything standing in it, and an authored camera that moves through it.",
547
+ "registryDependencies": [],
548
+ "files": [
549
+ {
550
+ "path": "components/camera-stage/camera-stage.tsx",
551
+ "content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type CameraStop = {\n /** The frame the camera is at these settings. */\n frame: number;\n /** Where the camera looks, in world coordinates. */\n x: number;\n y: number;\n /** How close it is. 1 shows the world at its own size. */\n scale?: number;\n /** Which depth is sharp. Items away from it blur. */\n focus?: number;\n};\n\nexport type CameraStageProps = {\n /** The camera's authored path. Held before the first and after the last. */\n stops: CameraStop[];\n /** Everything that exists in this world, placed with CameraItem. */\n children?: ReactNode;\n /** How strongly an out-of-focus depth blurs, in pixels per unit. */\n depthOfField?: number;\n /** Paint the brand background behind the world. */\n background?: boolean;\n};\n\nexport type CameraItemProps = {\n /** Where this sits in world coordinates, from its top left. */\n x: number;\n y: number;\n width?: number;\n height?: number;\n /** Distance from the focal plane. Zero is sharp when the camera focuses there. */\n depth?: number;\n children?: ReactNode;\n};\n\n/**\n * One thing standing somewhere in the world the camera moves through.\n *\n * World coordinates are design pixels, and this is the only place they are\n * scaled. Whatever stands here scales itself the way every component does, so\n * a registry component can be placed in a world without knowing it is in one.\n */\nexport const CameraItem = ({x, y, width, height, depth = 0, children}: CameraItemProps) => {\n const scale = useDesignScale();\n return (\n <div\n data-depth={depth}\n style={{\n position: \"absolute\",\n left: x * scale,\n top: y * scale,\n width: width === undefined ? undefined : width * scale,\n height: height === undefined ? undefined : height * scale,\n }}\n >\n {children}\n </div>\n );\n};\n\nconst valueAt = (stops: CameraStop[], frame: number, read: (stop: CameraStop) => number): number => {\n if (stops.length === 0) return 0;\n if (frame <= stops[0].frame) return read(stops[0]);\n const last = stops[stops.length - 1];\n if (frame >= last.frame) return read(last);\n for (let index = 0; index < stops.length - 1; index += 1) {\n const from = stops[index];\n const to = stops[index + 1];\n if (frame >= from.frame && frame <= to.frame) {\n return interpolate(frame, [from.frame, to.frame], [read(from), read(to)], {easing: Easing.standard});\n }\n }\n return read(last);\n};\n\n/**\n * One world, and a camera that moves through it.\n *\n * Most product video is cut together: a scene ends, another begins, and the\n * first one stops existing. Some of the best of it is not. It builds a single\n * flat world with everything already standing in it, and then simply moves a\n * camera, so a transition is the camera arriving somewhere rather than one\n * shot replacing another. The interface you were just looking at is still\n * there behind the headline, thrown out of focus, and that residue is what\n * makes the film feel continuous instead of assembled.\n *\n * That is impossible to express with scenes, because scenes are cuts by\n * construction. It needs world coordinates, one authored camera path, and a\n * focal plane, which is what this is.\n *\n * The camera is a list of stops rather than an animation: the position at any\n * frame is read from the path, so scrubbing backwards is exact and two renders\n * of the same frame agree. Depth is a number per item rather than a physical\n * distance, because what a video actually needs is \"this is the subject and\n * that is behind it\", not a lens simulation.\n */\nexport const CameraStage = ({stops, children, depthOfField = 8, background = true}: CameraStageProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const x = valueAt(stops, frame, (stop) => stop.x);\n const y = valueAt(stops, frame, (stop) => stop.y);\n const zoom = valueAt(stops, frame, (stop) => stop.scale ?? 1);\n const focus = valueAt(stops, frame, (stop) => stop.focus ?? 0);\n\n return (\n <Fill style={{background: background ? brand.colors.background : \"transparent\", overflow: \"hidden\"}}>\n {/* The world is moved under a fixed camera, which is the same thing as\n moving a camera over a fixed world and far cheaper to express. */}\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: \"50%\",\n // Only the camera transform lives here. Scaling the world as well\n // would scale its contents twice, since everything standing in it\n // already scales itself.\n transform: `scale(${zoom}) translate(${-x * scale}px, ${-y * scale}px)`,\n transformOrigin: \"0 0\",\n }}\n >\n {children}\n </div>\n {/* Blur is addressed by depth so an item can sit behind the subject\n without knowing where the camera is looking. */}\n <FocusBlur focus={focus} strength={depthOfField * scale} />\n </Fill>\n );\n};\n\n/**\n * Blur is set per depth as a stylesheet rather than per element, so a world of\n * fifty items costs one rule instead of fifty inline filters.\n */\nconst FocusBlur = ({focus, strength}: {focus: number; strength: number}) => {\n const depths = [-3, -2, -1, 0, 1, 2, 3];\n return (\n <style>\n {depths\n .map((depth) => {\n const blur = Math.abs(depth - focus) * strength;\n return `[data-depth=\"${depth}\"] { filter: blur(${blur.toFixed(2)}px); }`;\n })\n .join(\"\\n\")}\n </style>\n );\n};\n",
552
+ "target": "videos/components/camera-stage/camera-stage.tsx"
553
+ },
554
+ {
555
+ "path": "components/camera-stage/camera-stage.preview.tsx",
556
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CameraItem, CameraStage} from \"./camera-stage\";\n\n/**\n * A world with a headline standing in front of an interface, and a camera that\n * travels from one to the other. The interface never leaves; it goes soft.\n */\nconst Travelling = ({depthOfField}: {depthOfField?: number}) => (\n <CameraStage\n depthOfField={depthOfField}\n stops={[\n {frame: 0, x: 0, y: 0, scale: 1, focus: 0},\n {frame: 40, x: 0, y: 0, scale: 1, focus: 0},\n {frame: 90, x: 1500, y: 220, scale: 1.15, focus: 1},\n ]}\n >\n <CameraItem x={-620} y={-90} width={1240} depth={0}>\n <div style={{color: \"#0a0a0a\", fontSize: 108, fontWeight: 400, letterSpacing: \"-0.02em\", lineHeight: 1.05}}>\n Introducing Build Mode\n </div>\n </CameraItem>\n <CameraItem x={900} y={-260} width={1200} height={720} depth={1}>\n <div\n style={{\n background: \"#f3f3f3\",\n border: \"1px solid #e9e9e9\",\n borderRadius: 28,\n display: \"grid\",\n gap: 18,\n height: \"100%\",\n padding: 40,\n }}\n >\n <div style={{background: \"#e9e9e9\", borderRadius: 12, height: 46, width: \"42%\"}} />\n <div style={{background: \"#e9e9e9\", borderRadius: 12, height: 46, width: \"78%\"}} />\n <div style={{background: \"#e9e9e9\", borderRadius: 12, height: 46, width: \"60%\"}} />\n <div style={{background: \"#0a0a0a\", borderRadius: 14, height: 58, marginTop: 20, width: 220}} />\n </div>\n </CameraItem>\n </CameraStage>\n);\n\nexport default defineComponentPreview({\n title: \"Camera stage\",\n category: \"Motion\",\n description: \"One world with everything standing in it, and an authored camera that moves through it.\",\n component: Travelling,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n depthOfField: {type: \"number\", defaultValue: 8, min: 0, max: 24, step: 1},\n },\n examples: [\n {name: \"Travelling\", props: {}},\n {name: \"Deep focus\", props: {depthOfField: 0}},\n ],\n});\n",
557
+ "target": "videos/components/camera-stage/camera-stage.preview.tsx"
558
+ }
559
+ ],
560
+ "meta": {
561
+ "kind": "component",
562
+ "family": "Motion",
563
+ "namespaced": "@odori/camera-stage",
564
+ "contract": {
565
+ "aspectRatios": [
566
+ "16:9",
567
+ "9:16",
568
+ "1:1"
569
+ ],
570
+ "recommendedDurationInFrames": 150,
571
+ "minimumDurationInFrames": 60,
572
+ "entranceFrames": 0,
573
+ "exitFrames": 0,
574
+ "contentLimits": {
575
+ "stops": 8,
576
+ "items": 12
577
+ },
578
+ "reducedMotion": "the camera holds its first stop",
579
+ "requires": {
580
+ "fonts": [],
581
+ "audio": []
582
+ }
583
+ }
584
+ }
585
+ },
531
586
  {
532
587
  "name": "canvas-magnifier",
533
588
  "description": "A true enlarged detail lens over authored content.",
@@ -540,13 +595,13 @@
540
595
  },
541
596
  {
542
597
  "path": "components/canvas-magnifier/canvas-magnifier.preview.tsx",
543
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CanvasMagnifier} from \"./canvas-magnifier\";\n\nconst Detail = () => (\n <div style={{background: \"#080808\", inset: 0, position: \"absolute\"}}>\n {Array.from({length: 7}, (_, row) => (\n <div\n key={row}\n style={{\n color: row === 3 ? \"#f5f5f5\" : \"#5a5a5a\",\n fontFamily: \"ui-monospace, monospace\",\n fontSize: 34,\n left: 200,\n position: \"absolute\",\n top: 260 + row * 70,\n }}\n >\n {row === 3 ? \"duration: \\\"12s\\\",\" : `line ${row + 1}: const value = ${row * 7};`}\n </div>\n ))}\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Canvas magnifier\",\n category: \"Media and canvas\",\n description: \"A true enlarged detail lens over authored content.\",\n component: CanvasMagnifier,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n size: {type: \"number\", defaultValue: 460, min: 200, max: 900},\n zoom: {type: \"number\", defaultValue: 2.4, min: 1.2, max: 5, step: 0.1},\n },\n examples: [\n {name: \"Default\", props: {children: <Detail />, x: 520, y: 470}},\n {\n name: \"Travelling\",\n props: {\n children: <Detail />,\n path: [\n {frame: 0, x: 400, y: 300},\n {frame: 45, x: 520, y: 470, hold: 25},\n {frame: 80, x: 700, y: 640},\n ],\n },\n },\n ],\n});\n",
598
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CanvasMagnifier} from \"./canvas-magnifier\";\n\nconst Detail = () => (\n <div style={{background: \"#080808\", inset: 0, position: \"absolute\"}}>\n {Array.from({length: 7}, (_, row) => (\n <div\n key={row}\n style={{\n color: row === 3 ? \"#f5f5f5\" : \"#5a5a5a\",\n fontFamily: \"ui-monospace, monospace\",\n fontSize: 34,\n left: 200,\n position: \"absolute\",\n top: 260 + row * 70,\n }}\n >\n {row === 3 ? \"duration: \\\"12s\\\",\" : `line ${row + 1}: const value = ${row * 7};`}\n </div>\n ))}\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Canvas magnifier\",\n category: \"Media/Canvas\",\n description: \"A true enlarged detail lens over authored content.\",\n component: CanvasMagnifier,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n size: {type: \"number\", defaultValue: 460, min: 200, max: 900},\n zoom: {type: \"number\", defaultValue: 2.4, min: 1.2, max: 5, step: 0.1},\n },\n examples: [\n {name: \"Default\", props: {children: <Detail />, x: 520, y: 470}},\n {\n name: \"Travelling\",\n props: {\n children: <Detail />,\n path: [\n {frame: 0, x: 400, y: 300},\n {frame: 45, x: 520, y: 470, hold: 25},\n {frame: 80, x: 700, y: 640},\n ],\n },\n },\n ],\n});\n",
544
599
  "target": "videos/components/canvas-magnifier/canvas-magnifier.preview.tsx"
545
600
  }
546
601
  ],
547
602
  "meta": {
548
603
  "kind": "component",
549
- "family": "Media and canvas",
604
+ "family": "Media",
550
605
  "namespaced": "@odori/canvas-magnifier",
551
606
  "contract": {
552
607
  "aspectRatios": [
@@ -579,13 +634,13 @@
579
634
  },
580
635
  {
581
636
  "path": "components/canvas-stage/canvas-stage.preview.tsx",
582
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CanvasStage} from \"./canvas-stage\";\n\nexport default defineComponentPreview({\n title: \"Canvas stage\",\n category: \"Media and canvas\",\n description: \"Frame-driven canvas drawing with export parity.\",\n component: CanvasStage,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n count: {type: \"number\", defaultValue: 120, min: 10, max: 600},\n seed: {type: \"text\", defaultValue: \"field\"},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Dense\", props: {count: 400}},\n {name: \"Another seed\", props: {seed: \"launch\"}},\n ],\n});\n",
637
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CanvasStage} from \"./canvas-stage\";\n\nexport default defineComponentPreview({\n title: \"Canvas stage\",\n category: \"Media/Canvas\",\n description: \"Frame-driven canvas drawing with export parity.\",\n component: CanvasStage,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n count: {type: \"number\", defaultValue: 120, min: 10, max: 600},\n seed: {type: \"text\", defaultValue: \"field\"},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Dense\", props: {count: 400}},\n {name: \"Another seed\", props: {seed: \"launch\"}},\n ],\n});\n",
583
638
  "target": "videos/components/canvas-stage/canvas-stage.preview.tsx"
584
639
  }
585
640
  ],
586
641
  "meta": {
587
642
  "kind": "component",
588
- "family": "Media and canvas",
643
+ "family": "Media",
589
644
  "namespaced": "@odori/canvas-stage",
590
645
  "contract": {
591
646
  "aspectRatios": [
@@ -620,7 +675,7 @@
620
675
  },
621
676
  {
622
677
  "path": "components/captions/captions.preview.tsx",
623
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Captions} from \"./captions\";\n\nexport default defineComponentPreview({\n title: \"Captions\",\n category: \"Typography\",\n description: \"Frame-accurate caption cues for sound-off playback.\",\n component: Captions,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n examples: [\n {\n name: \"Two cues\",\n props: {\n cues: [\n {text: \"Preview needs no encode.\", fromFrame: 0, durationInFrames: 60},\n {text: \"Export freezes the approved cut.\", fromFrame: 62, durationInFrames: 70},\n ],\n },\n },\n ],\n});\n",
678
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Captions} from \"./captions\";\n\nexport default defineComponentPreview({\n title: \"Captions\",\n category: \"Typography/Captions\",\n description: \"Frame-accurate caption cues for sound-off playback.\",\n component: Captions,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n examples: [\n {\n name: \"Two cues\",\n props: {\n cues: [\n {text: \"Preview needs no encode.\", fromFrame: 0, durationInFrames: 60},\n {text: \"Export freezes the approved cut.\", fromFrame: 62, durationInFrames: 70},\n ],\n },\n },\n ],\n});\n",
624
679
  "target": "videos/components/captions/captions.preview.tsx"
625
680
  }
626
681
  ],
@@ -663,13 +718,13 @@
663
718
  },
664
719
  {
665
720
  "path": "components/carousel/carousel.preview.tsx",
666
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Carousel} from \"./carousel\";\n\nexport default defineComponentPreview({\n title: \"Carousel\",\n category: \"Media and canvas\",\n description: \"A paced media sequence with stable aspect handling.\",\n component: Carousel,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n holdInFrames: {type: \"number\", defaultValue: 45, min: 10, max: 150},\n changeInFrames: {type: \"number\", defaultValue: 14, min: 4, max: 60},\n indicator: {type: \"boolean\", defaultValue: true},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Fast\", props: {holdInFrames: 20, changeInFrames: 8}},\n ],\n});\n",
721
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Carousel} from \"./carousel\";\n\nexport default defineComponentPreview({\n title: \"Carousel\",\n category: \"Media/Footage\",\n description: \"A paced media sequence with stable aspect handling.\",\n component: Carousel,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n holdInFrames: {type: \"number\", defaultValue: 45, min: 10, max: 150},\n changeInFrames: {type: \"number\", defaultValue: 14, min: 4, max: 60},\n indicator: {type: \"boolean\", defaultValue: true},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Fast\", props: {holdInFrames: 20, changeInFrames: 8}},\n ],\n});\n",
667
722
  "target": "videos/components/carousel/carousel.preview.tsx"
668
723
  }
669
724
  ],
670
725
  "meta": {
671
726
  "kind": "component",
672
- "family": "Media and canvas",
727
+ "family": "Media",
673
728
  "namespaced": "@odori/carousel",
674
729
  "contract": {
675
730
  "aspectRatios": [
@@ -704,7 +759,7 @@
704
759
  },
705
760
  {
706
761
  "path": "components/character-rise/character-rise.preview.tsx",
707
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CharacterRise} from \"./character-rise\";\n\nexport default defineComponentPreview({\n title: \"Character rise\",\n category: \"Typography\",\n description: \"Characters rise with a crisp per-letter stagger.\",\n component: CharacterRise,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"Momentum\", maxLength: 22},\n detail: {type: \"text\", defaultValue: \"Motion with a purpose.\"},\n stagger: {type: \"number\", defaultValue: 2, min: 1, max: 8},\n },\n examples: [\n {name: \"Default\", props: {text: \"Momentum\", detail: \"Motion with a purpose.\"}},\n {name: \"Slow\", props: {text: \"Deliberate\", stagger: 5}},\n ],\n});\n",
762
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CharacterRise} from \"./character-rise\";\n\nexport default defineComponentPreview({\n title: \"Character rise\",\n category: \"Typography/Titles\",\n description: \"Characters rise with a crisp per-letter stagger.\",\n component: CharacterRise,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"Momentum\", maxLength: 22},\n detail: {type: \"text\", defaultValue: \"Motion with a purpose.\"},\n stagger: {type: \"number\", defaultValue: 2, min: 1, max: 8},\n },\n examples: [\n {name: \"Default\", props: {text: \"Momentum\", detail: \"Motion with a purpose.\"}},\n {name: \"Slow\", props: {text: \"Deliberate\", stagger: 5}},\n ],\n});\n",
708
763
  "target": "videos/components/character-rise/character-rise.preview.tsx"
709
764
  }
710
765
  ],
@@ -735,6 +790,134 @@
735
790
  }
736
791
  }
737
792
  },
793
+ {
794
+ "name": "chatgpt",
795
+ "description": "A chat composer whose starters clear as the prompt lands and the send control fills.",
796
+ "registryDependencies": [],
797
+ "files": [
798
+ {
799
+ "path": "components/chatgpt/chatgpt.tsx",
800
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type ChatGptProps = {\n /** The prompt that gets typed. */\n prompt: string;\n /** What sits in the field before the first character. */\n placeholder?: string;\n /**\n * Starters under the field. They clear as soon as typing starts, the way a\n * suggestion stops being a suggestion once you have your own idea.\n */\n suggestions?: string[];\n /** A mark you have the right to use, drawn above the composer. */\n logo?: ReactNode;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\nconst PALETTE = {\n dark: {page: \"#000000\", field: \"#212121\", edge: \"#303030\", ink: \"#FFFFFF\", faint: \"#9B9B9B\", accent: \"#FFFFFF\"},\n light: {page: \"#FFFFFF\", field: \"#F4F4F4\", edge: \"#E5E5E5\", ink: \"#0D0D0D\", faint: \"#8F8F8F\", accent: \"#0D0D0D\"},\n};\n\n/** ChatGPT sets its interface in the system face. */\nconst FONT = '-apple-system, system-ui, \"Segoe UI\", Helvetica, Arial, sans-serif';\n\n/**\n * A chat composer with starters, taking a prompt.\n *\n * Two things move and they move in sequence: the starters clear, then the\n * send control fills in. Staggering them keeps the frame from changing in two\n * places at once, which is what makes a composer shot read as deliberate\n * rather than busy.\n */\nexport const ChatGpt = ({\n prompt,\n placeholder = \"Ask anything\",\n suggestions = [\"Summarize a document\", \"Analyze data\", \"Make a plan\"],\n logo,\n theme = \"dark\",\n charactersPerSecond = 26,\n}: ChatGptProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const typed = useTyping(prompt, {from: 20, charactersPerSecond, chunk: 2});\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});\n // The starters leave first, then the control arms: one change at a time.\n const cleared = interpolate(typed.length > 0 ? frame : 0, [20, 30], [0, 1], {easing: Easing.standard});\n const armed = interpolate(typed.length > 0 ? frame : 0, [26, 34], [0, 1], {easing: Easing.standard});\n const onDark = theme === \"dark\";\n\n return (\n <Fill style={{alignItems: \"center\", background: colors.page, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n maxWidth: px(1120),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(18)}px)`,\n width: \"100%\",\n }}\n >\n {logo ? <div style={{marginBottom: px(40), textAlign: \"center\"}}>{logo}</div> : null}\n\n <div\n style={{\n alignItems: \"center\",\n background: colors.field,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n display: \"flex\",\n gap: px(18),\n padding: `${px(20)}px ${px(20)}px ${px(20)}px ${px(32)}px`,\n }}\n >\n <span\n style={{\n color: typed.length > 0 ? colors.ink : colors.faint,\n flex: 1,\n fontFamily: FONT,\n fontSize: px(32),\n letterSpacing: \"-0.01em\",\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {typed.length > 0 ? typed.text : placeholder}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(32),\n marginLeft: px(4),\n transform: `translateY(${px(5)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </span>\n\n {/* The idle microphone gives way to a filled send control. */}\n <span\n style={{\n alignItems: \"center\",\n background: `color-mix(in srgb, ${colors.accent} ${armed * 100}%, transparent)`,\n borderRadius: px(999),\n display: \"inline-flex\",\n flex: \"none\",\n height: px(56),\n justifyContent: \"center\",\n position: \"relative\",\n width: px(56),\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(26)} height={px(26)} style={{opacity: 1 - armed, position: \"absolute\"}}>\n <rect x=\"9\" y=\"3\" width=\"6\" height=\"11\" rx=\"3\" fill={colors.faint} />\n <path\n d=\"M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3\"\n fill=\"none\"\n stroke={colors.faint}\n strokeLinecap=\"round\"\n strokeWidth={1.8}\n />\n </svg>\n <svg\n viewBox=\"0 0 24 24\"\n width={px(24)}\n height={px(24)}\n style={{opacity: armed, position: \"absolute\", transform: `scale(${0.7 + armed * 0.3})`}}\n >\n <path\n d=\"M12 19V5M12 5l-6 6M12 5l6 6\"\n fill=\"none\"\n stroke={onDark ? \"#0D0D0D\" : \"#FFFFFF\"}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.4}\n />\n </svg>\n </span>\n </div>\n\n {suggestions.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: px(14),\n justifyContent: \"center\",\n marginTop: px(28),\n opacity: 1 - cleared,\n transform: `translateY(${cleared * px(-10)}px)`,\n }}\n >\n {suggestions.map((suggestion) => (\n <span\n key={suggestion}\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n color: colors.faint,\n fontFamily: FONT,\n fontSize: px(22),\n padding: `${px(12)}px ${px(22)}px`,\n }}\n >\n {suggestion}\n </span>\n ))}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
801
+ "target": "videos/components/chatgpt/chatgpt.tsx"
802
+ },
803
+ {
804
+ "path": "components/chatgpt/chatgpt.preview.tsx",
805
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ChatGpt} from \"./chatgpt\";\n\nexport default defineComponentPreview({\n title: \"ChatGPT\",\n category: \"Agents\",\n description: \"A chat composer whose starters clear as the prompt lands and the send control fills.\",\n component: ChatGpt,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n prompt: {type: \"text\", defaultValue: \"Turn last quarter's numbers into a one page brief.\", maxLength: 140},\n placeholder: {type: \"text\", defaultValue: \"Ask anything\", maxLength: 48},\n theme: {type: \"select\", defaultValue: \"dark\", options: [\"dark\", \"light\"]},\n charactersPerSecond: {type: \"number\", defaultValue: 26, min: 8, max: 60, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Light\", props: {theme: \"light\"}},\n {name: \"No starters\", props: {suggestions: []}},\n ],\n});\n",
806
+ "target": "videos/components/chatgpt/chatgpt.preview.tsx"
807
+ }
808
+ ],
809
+ "meta": {
810
+ "kind": "component",
811
+ "family": "Agents",
812
+ "namespaced": "@odori/chatgpt",
813
+ "contract": {
814
+ "aspectRatios": [
815
+ "16:9"
816
+ ],
817
+ "recommendedDurationInFrames": 180,
818
+ "minimumDurationInFrames": 90,
819
+ "entranceFrames": 16,
820
+ "exitFrames": 12,
821
+ "contentLimits": {
822
+ "prompt": 140,
823
+ "placeholder": 48
824
+ },
825
+ "reducedMotion": "starters hidden rather than cleared",
826
+ "requires": {
827
+ "fonts": [
828
+ "sans"
829
+ ],
830
+ "audio": []
831
+ }
832
+ }
833
+ }
834
+ },
835
+ {
836
+ "name": "claude",
837
+ "description": "An assistant composer typing a prompt, its voice control turning over into send.",
838
+ "registryDependencies": [],
839
+ "files": [
840
+ {
841
+ "path": "components/claude/claude.tsx",
842
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type ClaudeProps = {\n /** The prompt that gets typed. */\n prompt: string;\n /** What sits in the field before the first character. */\n placeholder?: string;\n /** The model chip under the field. An empty string hides it. */\n model?: string;\n /**\n * A mark drawn above the composer. Nothing ships here: pass a mark you have\n * the right to use, or leave it out and let the surface speak.\n */\n logo?: ReactNode;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\n/**\n * The surface carries its own palette rather than the brand's: a composer\n * recoloured to your product is no longer a picture of the assistant. Type is\n * the exception, because the brand's sans keeps the cut coherent and nobody\n * reads a UI screenshot for its typeface.\n */\nconst PALETTE = {\n dark: {page: \"#151515\", field: \"rgba(255,255,255,0.06)\", edge: \"#302F2C\", ink: \"#F0EFEC\", faint: \"#8F8B80\", accent: \"#C96442\"},\n light: {page: \"#FAF9F5\", field: \"#FFFFFF\", edge: \"#E3DFD4\", ink: \"#2A2825\", faint: \"#8A8578\", accent: \"#C96442\"},\n};\n\n/** Claude sets its interface in anthropic-sans on a near-black ground. */\nconst FONT = '\"anthropic-sans\", ui-sans-serif, system-ui, -apple-system, sans-serif';\n\n/**\n * An assistant composer with a prompt landing in it.\n *\n * The whole surface is one frame-driven state: characters appear, and the\n * button on the right turns over from the idle voice control into a send\n * arrow the moment there is something to send. That turn is the shot.\n * Everything else holds still so the eye has one thing to follow.\n */\nexport const Claude = ({\n prompt,\n placeholder = \"How can I help you today?\",\n model = \"Claude Opus 4.5\",\n logo,\n theme = \"dark\",\n charactersPerSecond = 26,\n}: ClaudeProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const typed = useTyping(prompt, {from: 14, charactersPerSecond, chunk: 2});\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});\n // The send control turns over once, over six frames from the first\n // character, and stays turned.\n const turn = interpolate(typed.length > 0 ? frame : 0, [14, 20], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", background: colors.page, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n maxWidth: px(1180),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(18)}px)`,\n width: \"100%\",\n }}\n >\n {logo ? <div style={{marginBottom: px(34)}}>{logo}</div> : null}\n\n <div\n style={{\n background: colors.field,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(26),\n boxShadow: `0 ${px(24)}px ${px(70)}px rgba(0,0,0,${theme === \"dark\" ? 0.42 : 0.1})`,\n padding: `${px(30)}px ${px(30)}px ${px(22)}px`,\n }}\n >\n <div\n style={{\n color: typed.length > 0 ? colors.ink : colors.faint,\n fontFamily: FONT,\n fontSize: px(34),\n letterSpacing: \"-0.01em\",\n lineHeight: 1.45,\n minHeight: px(96),\n whiteSpace: \"pre-wrap\",\n }}\n >\n {typed.length > 0 ? typed.text : placeholder}\n {typed.caret ? (\n <span\n style={{\n background: colors.accent,\n display: \"inline-block\",\n height: px(34),\n marginLeft: px(4),\n transform: `translateY(${px(5)}px)`,\n width: px(3),\n }}\n />\n ) : null}\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", justifyContent: \"space-between\", marginTop: px(18)}}>\n {model ? (\n <span\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n color: colors.faint,\n fontFamily: FONT,\n fontSize: px(20),\n padding: `${px(8)}px ${px(16)}px`,\n }}\n >\n {model}\n </span>\n ) : (\n <span />\n )}\n\n {/* One control, two faces. The idle face fades and shrinks as the\n armed face grows through it, so the turn reads as the same\n button changing its mind rather than two buttons swapping. */}\n <span\n style={{\n alignItems: \"center\",\n background: `color-mix(in srgb, ${colors.accent} ${turn * 100}%, transparent)`,\n border: `${px(1)}px solid color-mix(in srgb, ${colors.accent} ${turn * 100}%, ${colors.edge})`,\n borderRadius: px(999),\n display: \"inline-flex\",\n height: px(60),\n justifyContent: \"center\",\n position: \"relative\",\n width: px(60),\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n display: \"flex\",\n gap: px(4),\n opacity: 1 - turn,\n position: \"absolute\",\n transform: `scale(${1 - turn * 0.3})`,\n }}\n >\n {[14, 22, 16].map((height, index) => (\n <span\n key={index}\n style={{background: colors.faint, borderRadius: px(999), height: px(height), width: px(3)}}\n />\n ))}\n </span>\n <svg\n viewBox=\"0 0 24 24\"\n width={px(26)}\n height={px(26)}\n style={{opacity: turn, position: \"absolute\", transform: `scale(${0.7 + turn * 0.3})`}}\n >\n <path\n d=\"M12 19V5M12 5l-6 6M12 5l6 6\"\n fill=\"none\"\n stroke=\"#FFFFFF\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.4}\n />\n </svg>\n </span>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
843
+ "target": "videos/components/claude/claude.tsx"
844
+ },
845
+ {
846
+ "path": "components/claude/claude.preview.tsx",
847
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Claude} from \"./claude\";\n\nexport default defineComponentPreview({\n title: \"Claude\",\n category: \"Agents\",\n description: \"An assistant composer typing a prompt, its voice control turning over into send.\",\n component: Claude,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n prompt: {type: \"text\", defaultValue: \"Summarize the incident and draft the customer note.\", maxLength: 140},\n placeholder: {type: \"text\", defaultValue: \"How can I help you today?\", maxLength: 48},\n model: {type: \"text\", defaultValue: \"Claude Opus 4.5\", maxLength: 28},\n theme: {type: \"select\", defaultValue: \"dark\", options: [\"dark\", \"light\"]},\n charactersPerSecond: {type: \"number\", defaultValue: 26, min: 8, max: 60, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Light\", props: {theme: \"light\"}},\n {name: \"Bare\", props: {model: \"\", prompt: \"Ship it.\"}},\n ],\n});\n",
848
+ "target": "videos/components/claude/claude.preview.tsx"
849
+ }
850
+ ],
851
+ "meta": {
852
+ "kind": "component",
853
+ "family": "Agents",
854
+ "namespaced": "@odori/claude",
855
+ "contract": {
856
+ "aspectRatios": [
857
+ "16:9"
858
+ ],
859
+ "recommendedDurationInFrames": 180,
860
+ "minimumDurationInFrames": 90,
861
+ "entranceFrames": 16,
862
+ "exitFrames": 12,
863
+ "contentLimits": {
864
+ "prompt": 140,
865
+ "placeholder": 48,
866
+ "model": 28
867
+ },
868
+ "reducedMotion": "no button turn, send state from the first frame",
869
+ "requires": {
870
+ "fonts": [
871
+ "sans"
872
+ ],
873
+ "audio": []
874
+ }
875
+ }
876
+ }
877
+ },
878
+ {
879
+ "name": "claude-code",
880
+ "description": "Claude Code taking an instruction at its prompt and reporting back as it works.",
881
+ "registryDependencies": [],
882
+ "files": [
883
+ {
884
+ "path": "components/claude-code/claude-code.tsx",
885
+ "content": "import {Easing, Fill, interpolate, useDesignScale, useFrame, useTyping, typingFrames} from \"odori\";\n\n/** A step the agent reports, and what it found underneath. */\nexport type ClaudeCodeStep = {line: string; detail?: string};\n\nexport type ClaudeCodeProps = {\n /** What gets typed at the prompt, in plain English. */\n command: string;\n /** The working directory, as the welcome box states it. */\n cwd?: string;\n /** The model line under the prompt. */\n model?: string;\n /** Steps the agent prints back once the instruction is sent. */\n response?: (ClaudeCodeStep | string)[];\n /** The word the spinner uses while it works. */\n verb?: string;\n charactersPerSecond?: number;\n};\n\nconst INK = \"#E6E4DE\";\nconst FAINT = \"#7C7A73\";\nconst EDGE = \"#34322D\";\nconst ACCENT = \"#C96442\";\n/** A terminal sets its own face; this is the stack a session lands in. */\nconst FONT = '\"SF Mono\", \"JetBrains Mono\", \"Fira Code\", ui-monospace, Menlo, Consolas, monospace';\n/** The frames the spinner cycles, at the rate the real one turns. */\nconst SPINNER = [\"✻\", \"✳\", \"✽\", \"✻\", \"✺\", \"✸\"];\n\n/**\n * Claude Code waking up in a terminal and taking an instruction.\n *\n * The welcome box arrives whole rather than drawing itself in, because a CLI\n * does not animate its own banner and a video that pretends otherwise stops\n * looking like a terminal. The instruction is the only thing moving until it\n * is sent; then the agent's lines print under it one at a time, which is how\n * the tool behaves and the reason to show it rather than a finished\n * transcript.\n *\n * The status line under the prompt is the detail that makes it read as the\n * real thing: a spinner, the seconds it has been going, and the tokens it has\n * spent, all of which the tool keeps in front of you while it works.\n */\nexport const ClaudeCode = ({\n command,\n cwd = \"~/code/odori\",\n model = \"Opus 4.5\",\n response = [],\n verb = \"Working\",\n charactersPerSecond = 22,\n}: ClaudeCodeProps) => {\n const frame = useFrame();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const from = 26;\n const typed = useTyping(command, {from, charactersPerSecond});\n const sentAt = from + typingFrames(command, {charactersPerSecond}) + 14;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const sent = frame >= sentAt;\n\n const steps = response.map((item) => (typeof item === \"string\" ? {line: item} : item));\n\n /* The counters the tool keeps while it works. Both are derived from the\n frame so they are the same on every render of it. */\n const elapsed = Math.max(0, Math.floor((frame - sentAt) / 30));\n const tokens = Math.max(0, Math.round(((frame - sentAt) / 30) * 420));\n const spinner = SPINNER[Math.floor(frame / 4) % SPINNER.length];\n const spent = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${tokens}`;\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#1A1A18\", justifyContent: \"center\", padding: px(110)}}>\n <div\n style={{\n fontFamily: FONT,\n maxWidth: px(1300),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(14)}px)`,\n width: \"100%\",\n }}\n >\n <div style={{border: `${px(1)}px solid ${ACCENT}`, borderRadius: px(12), padding: `${px(26)}px ${px(30)}px`}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(14)}}>\n <span style={{color: ACCENT, fontSize: px(28)}}>✻</span>\n <span style={{color: INK, fontSize: px(28)}}>Welcome to Claude Code!</span>\n </div>\n <div style={{color: FAINT, fontSize: px(22), marginTop: px(18), paddingLeft: px(42)}}>cwd: {cwd}</div>\n </div>\n\n <div style={{color: FAINT, fontSize: px(21), marginTop: px(22), paddingLeft: px(6)}}>\n /help for help, /status for your current setup\n </div>\n\n <div\n style={{\n alignItems: \"center\",\n border: `${px(1)}px solid ${sent ? EDGE : ACCENT}`,\n borderRadius: px(10),\n display: \"flex\",\n gap: px(14),\n marginTop: px(22),\n padding: `${px(20)}px ${px(22)}px`,\n }}\n >\n <span style={{color: ACCENT, fontSize: px(26)}}>&gt;</span>\n <span style={{color: INK, fontSize: px(26), whiteSpace: \"pre\"}}>\n {typed.text}\n {sent ? null : (\n <span\n style={{\n background: typed.caret ? INK : \"transparent\",\n display: \"inline-block\",\n height: px(28),\n transform: `translateY(${px(5)}px)`,\n width: px(13),\n }}\n />\n )}\n </span>\n </div>\n\n {/* The hint row: shortcuts on the left, the mode and model on the\n right, which is where the tool keeps them. */}\n <div\n style={{\n alignItems: \"center\",\n color: FAINT,\n display: \"flex\",\n fontSize: px(19),\n marginTop: px(12),\n padding: `0 ${px(8)}px`,\n }}\n >\n <span>? for shortcuts</span>\n <span style={{marginLeft: \"auto\"}}>{model}</span>\n </div>\n\n {sent ? (\n <div\n style={{\n alignItems: \"center\",\n color: FAINT,\n display: \"flex\",\n fontSize: px(21),\n gap: px(12),\n marginTop: px(24),\n paddingLeft: px(6),\n }}\n >\n <span style={{color: ACCENT}}>{spinner}</span>\n <span style={{color: INK}}>{verb}…</span>\n <span>\n ({elapsed}s · ↑ {spent} tokens · esc to interrupt)\n </span>\n </div>\n ) : null}\n\n {steps.length > 0 ? (\n <div style={{display: \"grid\", gap: px(14), marginTop: px(24), paddingLeft: px(6)}}>\n {steps.map((step, index) => {\n // One line at a time: the agent reports as it goes, and a block\n // arriving at once would read as a transcript instead of work.\n const at = interpolate(frame, [sentAt + 10 + index * 14, sentAt + 22 + index * 14], [0, 1], {\n easing: Easing.standard,\n });\n return (\n <div key={step.line} style={{opacity: at}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(12)}}>\n <span style={{color: ACCENT, fontSize: px(20)}}>⏺</span>\n <span style={{color: INK, fontSize: px(22)}}>{step.line}</span>\n </div>\n {step.detail ? (\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(12), marginTop: px(6), paddingLeft: px(32)}}>\n <span style={{color: FAINT, fontSize: px(20)}}>⎿</span>\n <span style={{color: FAINT, fontSize: px(21)}}>{step.detail}</span>\n </div>\n ) : null}\n </div>\n );\n })}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
886
+ "target": "videos/components/claude-code/claude-code.tsx"
887
+ },
888
+ {
889
+ "path": "components/claude-code/claude-code.preview.tsx",
890
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ClaudeCode} from \"./claude-code\";\n\nexport default defineComponentPreview({\n title: \"Claude Code\",\n category: \"Agents\",\n description: \"Claude Code taking an instruction at its prompt and reporting back as it works.\",\n component: ClaudeCode,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n command: {type: \"text\", defaultValue: \"add a changelog video to the marketing site\", maxLength: 90},\n cwd: {type: \"text\", defaultValue: \"~/code/odori\", maxLength: 40},\n model: {type: \"text\", defaultValue: \"Opus 4.5\", maxLength: 24},\n verb: {type: \"text\", defaultValue: \"Investigating\", maxLength: 24},\n charactersPerSecond: {type: \"number\", defaultValue: 22, min: 8, max: 60, step: 1},\n },\n examples: [\n {\n name: \"Default\",\n props: {\n response: [\n {line: \"Read videos/changelog/video.tsx\", detail: \"Read 84 lines (ctrl+o to expand)\"},\n {line: \"Added the release cards and wired the cue\"},\n ],\n },\n },\n {\n name: \"Investigating\",\n props: {\n command: \"why is the export 40MB bigger than yesterday?\",\n response: [\n {line: \"Read packages/odori-cli/src/render.ts\", detail: \"Read 212 lines (ctrl+o to expand)\"},\n {line: \"Compared both manifests\", detail: \"2 differences found\"},\n {line: \"The toolchain pin changed the encoder defaults.\"},\n ],\n },\n },\n {name: \"Just the prompt\", props: {response: []}},\n ],\n});\n",
891
+ "target": "videos/components/claude-code/claude-code.preview.tsx"
892
+ }
893
+ ],
894
+ "meta": {
895
+ "kind": "component",
896
+ "family": "Agents",
897
+ "namespaced": "@odori/claude-code",
898
+ "contract": {
899
+ "aspectRatios": [
900
+ "16:9"
901
+ ],
902
+ "recommendedDurationInFrames": 270,
903
+ "minimumDurationInFrames": 120,
904
+ "entranceFrames": 16,
905
+ "exitFrames": 12,
906
+ "contentLimits": {
907
+ "command": 90,
908
+ "cwd": 40,
909
+ "model": 24
910
+ },
911
+ "reducedMotion": "command shown whole, caret still",
912
+ "requires": {
913
+ "fonts": [
914
+ "mono"
915
+ ],
916
+ "audio": []
917
+ }
918
+ }
919
+ }
920
+ },
738
921
  {
739
922
  "name": "code-proof",
740
923
  "description": "A framed source excerpt that reveals line by line.",
@@ -747,7 +930,7 @@
747
930
  },
748
931
  {
749
932
  "path": "components/code-proof/code-proof.preview.tsx",
750
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CodeProof} from \"./code-proof\";\n\nconst SAMPLE = `export const metadata = defineVideoMetadata({\n id: \"launch\",\n title: \"Product launch\",\n duration: \"12s\",\n});`;\n\nexport default defineComponentPreview({\n title: \"Code proof\",\n category: \"Developer proof\",\n description: \"A framed source excerpt that reveals line by line.\",\n component: CodeProof,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n code: {type: \"text\", defaultValue: SAMPLE, multiline: true},\n language: {type: \"select\", defaultValue: \"tsx\", options: [\"tsx\", \"ts\", \"shell\", \"json\"]},\n title: {type: \"text\", defaultValue: \"videos/launch/video.tsx\"},\n caption: {type: \"text\", defaultValue: \"Static metadata, ordinary JSX.\"},\n },\n examples: [\n {name: \"Default\", props: {code: SAMPLE, title: \"videos/launch/video.tsx\"}},\n {name: \"Focused line\", props: {code: SAMPLE, focus: [3]}},\n ],\n});\n",
933
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CodeProof} from \"./code-proof\";\n\nconst SAMPLE = `export const metadata = defineVideoMetadata({\n id: \"launch\",\n title: \"Product launch\",\n duration: \"12s\",\n});`;\n\nexport default defineComponentPreview({\n title: \"Code proof\",\n category: \"Developer proof/Code\",\n description: \"A framed source excerpt that reveals line by line.\",\n component: CodeProof,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n code: {type: \"text\", defaultValue: SAMPLE, multiline: true},\n language: {type: \"select\", defaultValue: \"tsx\", options: [\"tsx\", \"ts\", \"shell\", \"json\"]},\n title: {type: \"text\", defaultValue: \"videos/launch/video.tsx\"},\n caption: {type: \"text\", defaultValue: \"Static metadata, ordinary JSX.\"},\n },\n examples: [\n {name: \"Default\", props: {code: SAMPLE, title: \"videos/launch/video.tsx\"}},\n {name: \"Focused line\", props: {code: SAMPLE, focus: [3]}},\n ],\n});\n",
751
934
  "target": "videos/components/code-proof/code-proof.preview.tsx"
752
935
  }
753
936
  ],
@@ -790,7 +973,7 @@
790
973
  },
791
974
  {
792
975
  "path": "components/code-walkthrough/code-walkthrough.preview.tsx",
793
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CodeWalkthrough} from \"./code-walkthrough\";\n\nexport default defineComponentPreview({\n title: \"Code walkthrough\",\n category: \"Developer proof\",\n description: \"Progressive code reveal with authored focal regions.\",\n component: CodeWalkthrough,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"videos/launch/video.tsx\"},\n hold: {type: \"number\", defaultValue: 60, min: 24, max: 120},\n },\n examples: [\n {\n name: \"Metadata to timeline\",\n props: {\n code: `export const metadata = defineVideoMetadata({\n id: \"launch\",\n duration: \"8s\",\n});\n\nexport default function LaunchVideo() {\n return (\n <Video>\n <Scene duration=\"5s\">\n <TitleReveal title=\"Ship the story.\" />\n </Scene>\n </Video>\n );\n}`,\n steps: [\n {from: 1, to: 4, note: \"Static metadata, so discovery never runs your component.\"},\n {from: 6, to: 13, note: \"Ordinary JSX for the timeline.\"},\n {from: 8, to: 10, note: \"Scenes are ordered, not numbered.\"},\n ],\n },\n },\n ],\n});\n",
976
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CodeWalkthrough} from \"./code-walkthrough\";\n\nexport default defineComponentPreview({\n title: \"Code walkthrough\",\n category: \"Developer proof/Code\",\n description: \"Progressive code reveal with authored focal regions.\",\n component: CodeWalkthrough,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"videos/launch/video.tsx\"},\n hold: {type: \"number\", defaultValue: 60, min: 24, max: 120},\n },\n examples: [\n {\n name: \"Metadata to timeline\",\n props: {\n code: `export const metadata = defineVideoMetadata({\n id: \"launch\",\n duration: \"8s\",\n});\n\nexport default function LaunchVideo() {\n return (\n <Video>\n <Scene duration=\"5s\">\n <TitleReveal title=\"Ship the story.\" />\n </Scene>\n </Video>\n );\n}`,\n steps: [\n {from: 1, to: 4, note: \"Static metadata, so discovery never runs your component.\"},\n {from: 6, to: 13, note: \"Ordinary JSX for the timeline.\"},\n {from: 8, to: 10, note: \"Scenes are ordered, not numbered.\"},\n ],\n },\n },\n ],\n});\n",
794
977
  "target": "videos/components/code-walkthrough/code-walkthrough.preview.tsx"
795
978
  }
796
979
  ],
@@ -822,6 +1005,49 @@
822
1005
  }
823
1006
  }
824
1007
  },
1008
+ {
1009
+ "name": "combobox",
1010
+ "description": "A field filtering a list as it is typed into, rows collapsing as they stop matching.",
1011
+ "registryDependencies": [],
1012
+ "files": [
1013
+ {
1014
+ "path": "components/combobox/combobox.tsx",
1015
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from \"odori\";\n\nexport type ComboboxProps = {\n /** What gets typed into the field. */\n query: string;\n /** Everything the list could show. */\n options?: string[];\n /** The label above the field. */\n label?: string;\n /** Frame the list settles on one option and it is picked. */\n chooseAt?: number;\n charactersPerSecond?: number;\n};\n\n/**\n * A field that filters a list as it is typed into.\n *\n * The list narrowing is the whole demonstration, so the rows that fall away\n * are not simply gone: they collapse, which is what makes the filtering\n * legible as a consequence of the typing rather than as three separate\n * screens. Matching text is marked in the surviving rows for the same reason.\n */\nexport const Combobox = ({\n query,\n options = [\n \"components/terminal\",\n \"components/terminal-zoom\",\n \"components/title-reveal\",\n \"components/timeline\",\n \"components/toast-stack\",\n \"components/table-focus\",\n ],\n label = \"Add a component\",\n chooseAt = 96,\n charactersPerSecond = 12,\n}: ComboboxProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const typed = useTyping(query, {from: 20, charactersPerSecond});\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const needle = typed.text.toLowerCase();\n const chosen = options.find((option) => option.toLowerCase().includes(needle)) ?? options[0];\n const picked = interpolate(frame, [chooseAt, chooseAt + 10], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#09090B\", justifyContent: \"center\", padding: px(120)}}>\n <div style={{fontFamily: brand.typography.sans, maxWidth: px(860), opacity: enter, width: \"100%\"}}>\n <div style={{color: \"#8A8A93\", fontSize: px(20), marginBottom: px(12)}}>{label}</div>\n\n <div\n style={{\n background: \"#0C0C0E\",\n border: `${px(1)}px solid ${typed.length > 0 ? \"#3A3A44\" : \"#1F1F23\"}`,\n borderRadius: px(12),\n color: typed.length > 0 ? \"#FAFAFA\" : \"#6E6E78\",\n fontFamily: brand.typography.mono,\n fontSize: px(26),\n padding: `${px(20)}px ${px(22)}px`,\n }}\n >\n {typed.length > 0 ? typed.text : \"Search components…\"}\n {typed.caret ? (\n <span\n style={{\n background: \"#FAFAFA\",\n display: \"inline-block\",\n height: px(26),\n marginLeft: px(3),\n transform: `translateY(${px(4)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </div>\n\n <div\n style={{\n background: \"#0C0C0E\",\n border: `${px(1)}px solid #1F1F23`,\n borderRadius: px(12),\n marginTop: px(10),\n overflow: \"hidden\",\n padding: px(8),\n }}\n >\n {options.map((option) => {\n const matches = option.toLowerCase().includes(needle);\n // A row that stops matching collapses rather than vanishing, so\n // the list is seen to narrow.\n const shown = matches ? 1 : 0;\n const isChosen = option === chosen;\n return (\n <div\n key={option}\n style={{\n background: isChosen && picked > 0 ? `color-mix(in srgb, #26262C ${picked * 100}%, transparent)` : \"transparent\",\n borderRadius: px(8),\n color: matches ? \"#FAFAFA\" : \"#6E6E78\",\n fontFamily: brand.typography.mono,\n fontSize: px(22),\n height: shown ? px(56) : 0,\n lineHeight: `${px(56)}px`,\n opacity: shown,\n overflow: \"hidden\",\n paddingLeft: px(16),\n transition: \"none\",\n }}\n >\n {option}\n </div>\n );\n })}\n </div>\n </div>\n </Fill>\n );\n};\n",
1016
+ "target": "videos/components/combobox/combobox.tsx"
1017
+ },
1018
+ {
1019
+ "path": "components/combobox/combobox.preview.tsx",
1020
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Combobox} from \"./combobox\";\n\nexport default defineComponentPreview({\n title: \"Combobox\",\n category: \"Interface/Controls\",\n description: \"A field filtering a list as it is typed into, rows collapsing as they stop matching.\",\n component: Combobox,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n query: {type: \"text\", defaultValue: \"term\", maxLength: 32},\n label: {type: \"text\", defaultValue: \"Add a component\", maxLength: 32},\n chooseAt: {type: \"number\", defaultValue: 96, min: 40, max: 200, step: 4},\n charactersPerSecond: {type: \"number\", defaultValue: 12, min: 4, max: 40, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"One match\", props: {query: \"toast\", chooseAt: 110}},\n ],\n});\n",
1021
+ "target": "videos/components/combobox/combobox.preview.tsx"
1022
+ }
1023
+ ],
1024
+ "meta": {
1025
+ "kind": "component",
1026
+ "family": "Interface",
1027
+ "namespaced": "@odori/combobox",
1028
+ "contract": {
1029
+ "aspectRatios": [
1030
+ "16:9",
1031
+ "1:1"
1032
+ ],
1033
+ "recommendedDurationInFrames": 180,
1034
+ "minimumDurationInFrames": 75,
1035
+ "entranceFrames": 14,
1036
+ "exitFrames": 10,
1037
+ "contentLimits": {
1038
+ "query": 32,
1039
+ "label": 32
1040
+ },
1041
+ "reducedMotion": "query shown whole, list filtered",
1042
+ "requires": {
1043
+ "fonts": [
1044
+ "sans"
1045
+ ],
1046
+ "audio": []
1047
+ }
1048
+ }
1049
+ }
1050
+ },
825
1051
  {
826
1052
  "name": "command-menu",
827
1053
  "description": "Keyboard-first action search and selection flow.",
@@ -834,13 +1060,13 @@
834
1060
  },
835
1061
  {
836
1062
  "path": "components/command-menu/command-menu.preview.tsx",
837
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CommandMenu} from \"./command-menu\";\n\nexport default defineComponentPreview({\n title: \"Command menu\",\n category: \"Developer proof\",\n description: \"Keyboard-first action search and selection flow.\",\n component: CommandMenu,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n query: {type: \"text\", defaultValue: \"exp\", maxLength: 24},\n selectedIndex: {type: \"number\", defaultValue: 0, min: 0, max: 5},\n },\n examples: [\n {\n name: \"Export\",\n props: {\n query: \"exp\",\n items: [\"Export video\", \"Export still\", \"Open Studio\", \"Run contract tests\"],\n },\n },\n ],\n});\n",
1063
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CommandMenu} from \"./command-menu\";\n\nexport default defineComponentPreview({\n title: \"Command menu\",\n category: \"Interface/Controls\",\n description: \"Keyboard-first action search and selection flow.\",\n component: CommandMenu,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n query: {type: \"text\", defaultValue: \"exp\", maxLength: 24},\n selectedIndex: {type: \"number\", defaultValue: 0, min: 0, max: 5},\n },\n examples: [\n {\n name: \"Export\",\n props: {\n query: \"exp\",\n items: [\"Export video\", \"Export still\", \"Open Studio\", \"Run contract tests\"],\n },\n },\n ],\n});\n",
838
1064
  "target": "videos/components/command-menu/command-menu.preview.tsx"
839
1065
  }
840
1066
  ],
841
1067
  "meta": {
842
1068
  "kind": "component",
843
- "family": "Developer proof",
1069
+ "family": "Interface",
844
1070
  "namespaced": "@odori/command-menu",
845
1071
  "contract": {
846
1072
  "aspectRatios": [
@@ -934,20 +1160,153 @@
934
1160
  "namespaced": "@odori/connection-story",
935
1161
  "contract": {
936
1162
  "aspectRatios": [
937
- "16:9"
1163
+ "16:9"
1164
+ ],
1165
+ "recommendedDurationInFrames": 150,
1166
+ "minimumDurationInFrames": 75,
1167
+ "entranceFrames": 26,
1168
+ "exitFrames": 10,
1169
+ "contentLimits": {
1170
+ "nodes": 4,
1171
+ "label": 18
1172
+ },
1173
+ "reducedMotion": "connects without sweeping links",
1174
+ "requires": {
1175
+ "fonts": [
1176
+ "sans"
1177
+ ],
1178
+ "audio": []
1179
+ }
1180
+ }
1181
+ }
1182
+ },
1183
+ {
1184
+ "name": "context-menu",
1185
+ "description": "The menu that appears where you clicked, anchored to its own corner.",
1186
+ "registryDependencies": [],
1187
+ "files": [
1188
+ {
1189
+ "path": "components/context-menu/context-menu.tsx",
1190
+ "content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type ContextMenuItem = {\n label: string;\n /** A glyph before the label. */\n icon?: string;\n};\n\nexport type ContextMenuProps = {\n /** The surface the menu opens over. */\n children?: ReactNode;\n items: ContextMenuItem[];\n /** Where the menu's corner sits, as a fraction of the frame. */\n x?: number;\n y?: number;\n /** Frame the menu opens on. */\n openAt?: number;\n /** Frame an item is chosen. Counts from the top, zero based. */\n chooseAt?: number;\n /** Which item is chosen. */\n chosen?: number;\n};\n\n/**\n * The menu that appears where you clicked.\n *\n * A command palette answers \"what can I do anywhere\"; this answers \"what can I\n * do to this\", which is why it is anchored to a point rather than centred, and\n * why it has no query. Product video needs it constantly: the moment a demo\n * right-clicks a line of code or a row in a table, the menu is the whole\n * point of the shot.\n *\n * It opens from its own corner rather than fading in place, because that is\n * where the click was, and the corner is what tells the viewer the menu\n * belongs to the thing underneath rather than to the window.\n */\nexport const ContextMenu = ({\n children,\n items,\n x = 0.42,\n y = 0.3,\n openAt = 12,\n chooseAt,\n chosen = 0,\n}: ContextMenuProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const open = interpolate(frame, [openAt, openAt + 10], [0, 1], {easing: Easing.standard});\n const highlight = chooseAt === undefined ? 0 : interpolate(frame, [chooseAt - 6, chooseAt], [0, 1], {easing: Easing.standard});\n const press = chooseAt === undefined ? 0 : interpolate(frame, [chooseAt, chooseAt + 5, chooseAt + 12], [0, 1, 0], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"stretch\", justifyContent: \"stretch\", padding: 0, position: \"relative\"}}>\n {children}\n <div\n style={{\n background: brand.colors.background,\n border: `${Math.max(1, scale)}px solid color-mix(in srgb, ${brand.colors.foreground} 12%, transparent)`,\n borderRadius: 16 * scale,\n boxShadow: `0 ${30 * scale}px ${70 * scale}px color-mix(in srgb, ${brand.colors.foreground} 18%, transparent)`,\n left: `${x * 100}%`,\n opacity: open,\n padding: 8 * scale,\n position: \"absolute\",\n top: `${y * 100}%`,\n // Anchored at the corner the click happened on.\n transform: `scale(${0.92 + open * 0.08})`,\n transformOrigin: \"top left\",\n }}\n >\n {items.map((item, index) => {\n const active = index === chosen ? highlight : 0;\n return (\n <div\n key={item.label}\n style={{\n alignItems: \"center\",\n background:\n active > 0\n ? `color-mix(in srgb, ${brand.colors.foreground} ${6 + active * 6 + press * 4}%, transparent)`\n : \"transparent\",\n borderRadius: 10 * scale,\n color: brand.colors.foreground,\n display: \"flex\",\n fontSize: 26 * scale,\n gap: 14 * scale,\n padding: `${12 * scale}px ${18 * scale}px`,\n whiteSpace: \"nowrap\",\n }}\n >\n {item.icon ? (\n <span style={{color: brand.colors.muted, fontSize: 24 * scale}}>{item.icon}</span>\n ) : null}\n {item.label}\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
1191
+ "target": "videos/components/context-menu/context-menu.tsx"
1192
+ },
1193
+ {
1194
+ "path": "components/context-menu/context-menu.preview.tsx",
1195
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ContextMenu} from \"./context-menu\";\n\nexport default defineComponentPreview({\n title: \"Context menu\",\n category: \"Interface/Controls\",\n description: \"The menu that appears where you clicked, anchored to its own corner.\",\n component: ContextMenu,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n x: {type: \"number\", defaultValue: 0.42, min: 0, max: 0.8, step: 0.02},\n y: {type: \"number\", defaultValue: 0.3, min: 0, max: 0.8, step: 0.02},\n openAt: {type: \"number\", defaultValue: 12, min: 0, max: 90},\n },\n examples: [\n {\n name: \"On a line of code\",\n props: {\n items: [{label: \"Ask Cursor\", icon: \"✦\"}, {label: \"Copy permalink\", icon: \"⧉\"}, {label: \"Copy code\", icon: \"⌘\"}],\n chooseAt: 44,\n chosen: 0,\n },\n },\n {\n name: \"Short menu\",\n props: {items: [{label: \"Rename\"}, {label: \"Duplicate\"}, {label: \"Delete\"}], chooseAt: 40, chosen: 2},\n },\n ],\n});\n",
1196
+ "target": "videos/components/context-menu/context-menu.preview.tsx"
1197
+ }
1198
+ ],
1199
+ "meta": {
1200
+ "kind": "component",
1201
+ "family": "Interface",
1202
+ "namespaced": "@odori/context-menu",
1203
+ "contract": {
1204
+ "aspectRatios": [
1205
+ "16:9",
1206
+ "9:16",
1207
+ "1:1"
1208
+ ],
1209
+ "recommendedDurationInFrames": 120,
1210
+ "minimumDurationInFrames": 48,
1211
+ "entranceFrames": 24,
1212
+ "exitFrames": 10,
1213
+ "contentLimits": {
1214
+ "items": 6,
1215
+ "label": 24
1216
+ },
1217
+ "reducedMotion": "the menu appears without scaling from its corner",
1218
+ "requires": {
1219
+ "fonts": [
1220
+ "sans"
1221
+ ],
1222
+ "audio": []
1223
+ }
1224
+ }
1225
+ }
1226
+ },
1227
+ {
1228
+ "name": "control-stage",
1229
+ "description": "One product control, staged large enough to be the subject, pressed on a frame.",
1230
+ "registryDependencies": [],
1231
+ "files": [
1232
+ {
1233
+ "path": "components/control-stage/control-stage.tsx",
1234
+ "content": "import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type StagedControl = {\n /** A button by default; an input is a field that types itself. */\n kind?: \"button\" | \"input\";\n /** The button's text, or the field's placeholder. */\n label?: string;\n /** A glyph before the label. */\n icon?: string;\n /** Which surface the control is drawn on. */\n tone?: \"neutral\" | \"dark\" | \"accent\" | \"positive\";\n /** Frame the button is pressed. */\n pressAt?: number;\n /** What types into a field. */\n typed?: string;\n /** Frame the typing starts. */\n typeFrom?: number;\n};\n\nexport type ControlStageProps = {\n /** The controls, laid out in a row. */\n controls: StagedControl[];\n /** Characters typed per second in a field. */\n charactersPerSecond?: number;\n /** How large the controls are drawn. A close-up is the point. */\n size?: number;\n};\n\n/**\n * One control, staged large enough to be the subject.\n *\n * Product video keeps needing the same shot: a single button or field, alone\n * on the brand's surface, doing the one thing the sentence just claimed. Shown\n * inside a full interface it would be a detail among fifty; shown this size it\n * is the argument, and the viewer has nowhere else to look.\n *\n * The control is drawn the way `chip-caption` draws an inline chip — same\n * radius, same tones, same icon-then-label — so a sentence can name a control\n * and the next scene can cut to it at scale without the two disagreeing about\n * what the product looks like.\n *\n * Pressing is a frame, not a state a caller toggles: the control settles a\n * little smaller and darker for a few frames, which is what a press looks like\n * and what a cursor arriving from `cursor-focus` should land on.\n */\nexport const ControlStage = ({controls, charactersPerSecond = 14, size = 1}: ControlStageProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale() * size;\n\n const surfaceFor = (tone: StagedControl[\"tone\"]) => {\n if (tone === \"dark\") return {background: brand.colors.foreground, color: brand.colors.background, border: \"transparent\"};\n if (tone === \"accent\") return {background: brand.colors.accent, color: brand.colors.background, border: \"transparent\"};\n if (tone === \"positive\") return {background: \"#0f8a4a\", color: \"#ffffff\", border: \"transparent\"};\n return {\n background: brand.colors.background,\n color: brand.colors.foreground,\n border: `color-mix(in srgb, ${brand.colors.foreground} 14%, transparent)`,\n };\n };\n\n return (\n <Fill style={{alignItems: \"center\", background: brand.colors.surface, justifyContent: \"center\"}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: 20 * scale}}>\n {controls.map((control, index) => {\n const surface = surfaceFor(control.tone);\n const press =\n control.pressAt === undefined\n ? 0\n : interpolate(frame, [control.pressAt, control.pressAt + 4, control.pressAt + 12], [0, 1, 0], {\n easing: Easing.standard,\n });\n\n if (control.kind === \"input\") {\n const from = control.typeFrom ?? 0;\n const text = control.typed ?? \"\";\n const visible = Math.max(0, Math.min(text.length, Math.floor(((frame - from) / 30) * charactersPerSecond)));\n const typing = visible > 0 && visible < text.length;\n return (\n <div\n key={index}\n style={{\n alignItems: \"center\",\n background: brand.colors.background,\n border: `${Math.max(1, scale)}px solid color-mix(in srgb, ${brand.colors.foreground} 12%, transparent)`,\n borderRadius: 18 * scale,\n color: visible > 0 ? brand.colors.foreground : brand.colors.muted,\n display: \"flex\",\n fontSize: 34 * scale,\n height: 78 * scale,\n minWidth: 520 * scale,\n padding: `0 ${26 * scale}px`,\n }}\n >\n {visible > 0 ? text.slice(0, visible) : control.label}\n {/* The caret blinks on a frame cycle, and rests while typing. */}\n <span\n style={{\n background: brand.colors.foreground,\n display: \"inline-block\",\n height: 40 * scale,\n marginLeft: 3 * scale,\n opacity: typing || Math.floor(frame / 15) % 2 === 0 ? 1 : 0,\n width: 2 * scale,\n }}\n />\n </div>\n );\n }\n\n return (\n <div\n key={index}\n style={{\n alignItems: \"center\",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 18 * scale,\n color: surface.color,\n display: \"inline-flex\",\n filter: `brightness(${1 - press * 0.12})`,\n fontSize: 34 * scale,\n fontWeight: 500,\n gap: 12 * scale,\n height: 78 * scale,\n letterSpacing: \"-0.01em\",\n padding: `0 ${30 * scale}px`,\n transform: `scale(${1 - press * 0.03})`,\n whiteSpace: \"nowrap\",\n }}\n >\n {control.icon ? <span style={{fontSize: 30 * scale}}>{control.icon}</span> : null}\n {control.label}\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
1235
+ "target": "videos/components/control-stage/control-stage.tsx"
1236
+ },
1237
+ {
1238
+ "path": "components/control-stage/control-stage.preview.tsx",
1239
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ControlStage} from \"./control-stage\";\n\nexport default defineComponentPreview({\n title: \"Control stage\",\n category: \"Interface/Chrome\",\n description: \"One product control, staged large enough to be the subject, pressed on a frame.\",\n component: ControlStage,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n size: {type: \"number\", defaultValue: 1, min: 0.5, max: 2, step: 0.1},\n charactersPerSecond: {type: \"number\", defaultValue: 14, min: 4, max: 40},\n },\n examples: [\n {\n name: \"Pressed\",\n props: {controls: [{label: \"New\", icon: \"+\", pressAt: 30}]},\n },\n {\n name: \"Field and submit\",\n props: {\n controls: [\n {kind: \"input\", label: \"Repository name\", typed: \"everysphere-test\", typeFrom: 8},\n {label: \"Create Repo\", tone: \"dark\", pressAt: 62},\n ],\n },\n },\n {\n name: \"Confirming\",\n props: {controls: [{label: \"Squash and merge\", tone: \"positive\", pressAt: 34}]},\n },\n ],\n});\n",
1240
+ "target": "videos/components/control-stage/control-stage.preview.tsx"
1241
+ }
1242
+ ],
1243
+ "meta": {
1244
+ "kind": "component",
1245
+ "family": "Interface",
1246
+ "namespaced": "@odori/control-stage",
1247
+ "contract": {
1248
+ "aspectRatios": [
1249
+ "16:9",
1250
+ "9:16",
1251
+ "1:1"
1252
+ ],
1253
+ "recommendedDurationInFrames": 120,
1254
+ "minimumDurationInFrames": 48,
1255
+ "entranceFrames": 24,
1256
+ "exitFrames": 10,
1257
+ "contentLimits": {
1258
+ "controls": 3,
1259
+ "label": 22
1260
+ },
1261
+ "reducedMotion": "the press lands without the settle",
1262
+ "requires": {
1263
+ "fonts": [
1264
+ "sans"
1265
+ ],
1266
+ "audio": []
1267
+ }
1268
+ }
1269
+ }
1270
+ },
1271
+ {
1272
+ "name": "crt-terminal",
1273
+ "description": "A terminal on a cathode ray tube: bowed glass, unconverged beam, phosphor bloom and a rolling shadow mask.",
1274
+ "registryDependencies": [],
1275
+ "files": [
1276
+ {
1277
+ "path": "components/crt-terminal/crt-terminal.tsx",
1278
+ "content": "import {Fill, typedAt, useCanvas, useVideo} from \"odori\";\n\nexport type CrtTerminalProps = {\n /** Lines already on the screen when the shot opens. */\n history?: string[];\n /** The line typed at the prompt. */\n command?: string;\n /** Lines printed after the command is entered. */\n response?: string[];\n /** What the shell prints before the command. */\n prompt?: string;\n /** The phosphor. Green is a P1 tube, amber a P3. */\n phosphor?: \"green\" | \"amber\" | \"white\";\n /** How hard the tube is driven, 0 to 1. Raises glow, bloom and flicker. */\n intensity?: number;\n /** Characters revealed per second. */\n charactersPerSecond?: number;\n};\n\nconst PHOSPHOR = {\n green: {ink: [126, 255, 148], glow: \"rgba(80,255,120,\"},\n amber: {ink: [255, 176, 60], glow: \"rgba(255,176,60,\"},\n white: {ink: [222, 236, 255], glow: \"rgba(200,225,255,\"},\n};\n\n/**\n * A terminal on a cathode ray tube, drawn as pixels rather than as DOM.\n *\n * Everything that makes a CRT look like a CRT happens after the text exists,\n * which is exactly what a canvas is for and what live DOM cannot do: the glass\n * bulges, so lines bow away from the centre; the beam is not perfectly\n * converged, so red and blue sit a hair either side of green; the phosphor\n * blooms, so bright text carries a halo; the shadow mask leaves scanlines; and\n * the supply is imperfect, so the whole image breathes.\n *\n * Every one of those is a function of the frame rather than of wall time,\n * including the flicker and the roll, so the same frame draws the same tube\n * twice and two export workers meeting at a chunk boundary agree.\n */\nexport const CrtTerminal = ({\n history = [\"ODORI SYSTEM v0.0.3\", \"READY.\"],\n command = \"render launch --format mp4\",\n response = [\"CAPTURING 360 FRAMES\", \"ENCODING H.264\", \"DONE. OUT/LAUNCH.MP4\"],\n prompt = \">\",\n phosphor = \"green\",\n intensity = 0.6,\n charactersPerSecond = 16,\n}: CrtTerminalProps) => {\n const {width, height} = useVideo();\n const tube = PHOSPHOR[phosphor];\n\n const canvas = useCanvas(\n (context, {frame, width: w, height: h}) => {\n const drive = Math.min(1, Math.max(0, intensity));\n const [r, g, b] = tube.ink;\n const size = Math.round(h / 26);\n const lineHeight = size * 1.55;\n const left = w * 0.11;\n const top = h * 0.16;\n\n // The supply breathes. Two slow sines rather than one, so the wobble\n // never settles into an obvious beat.\n const flicker = 1 - drive * (0.03 + 0.022 * Math.sin(frame * 0.7) + 0.012 * Math.sin(frame * 0.31));\n\n context.fillStyle = \"#050705\";\n context.fillRect(0, 0, w, h);\n\n // The tube's own glow, before anything is written on it.\n const halo = context.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.max(w, h) * 0.62);\n halo.addColorStop(0, `${tube.glow}${0.055 * drive})`);\n halo.addColorStop(1, \"rgba(0,0,0,0)\");\n context.fillStyle = halo;\n context.fillRect(0, 0, w, h);\n\n const typed = typedAt(command, frame, {from: 20, charactersPerSecond});\n const enteredAt = 20 + Math.ceil((command.length / charactersPerSecond) * 30) + 10;\n const lines = [\n ...history.map((line) => ({text: line, dim: 0.72})),\n {text: `${prompt} ${typed.text}${typed.caret && frame < enteredAt ? \"█\" : \"\"}`, dim: 1},\n ...response\n .filter((_, index) => frame >= enteredAt + 12 + index * 16)\n .map((line) => ({text: line, dim: 0.86})),\n ];\n\n context.textBaseline = \"top\";\n context.font = `${size}px ui-monospace, \"SF Mono\", Menlo, monospace`;\n\n lines.forEach((line, index) => {\n const y = top + index * lineHeight;\n // The glass bulges: a line further from the centre bows outward and\n // its ends sit a touch lower than its middle.\n const fromCentre = (y + size / 2 - h / 2) / (h / 2);\n const bow = fromCentre * fromCentre * h * 0.012;\n const x = left + fromCentre * fromCentre * w * 0.006;\n const alpha = line.dim * flicker;\n\n // Bloom first, so the text sits inside its own halo.\n context.globalCompositeOperation = \"lighter\";\n context.shadowColor = `${tube.glow}${0.55 * drive})`;\n context.shadowBlur = size * (0.5 + drive * 0.9);\n\n // The beam is not perfectly converged.\n const split = 1 + drive * 1.6;\n context.fillStyle = `rgba(${r},60,60,${alpha * 0.5})`;\n context.fillText(line.text, x - split, y + bow);\n context.fillStyle = `rgba(60,60,${b},${alpha * 0.5})`;\n context.fillText(line.text, x + split, y + bow);\n context.fillStyle = `rgba(${r},${g},${b},${alpha})`;\n context.fillText(line.text, x, y + bow);\n\n context.shadowBlur = 0;\n context.globalCompositeOperation = \"source-over\";\n });\n\n // The shadow mask. Every third line is dark, and the whole grille rolls\n // slowly, which is the artefact a phone camera sees on a real tube.\n const roll = (frame * 0.6) % 3;\n context.fillStyle = `rgba(0,0,0,${0.16 + drive * 0.2})`;\n for (let y = -3 + roll; y < h; y += 3) context.fillRect(0, y, w, 1);\n\n // A brighter band drifting down the screen, once every few seconds.\n const band = ((frame * 2.2) % (h + 400)) - 200;\n const sweep = context.createLinearGradient(0, band - 160, 0, band + 160);\n sweep.addColorStop(0, \"rgba(255,255,255,0)\");\n sweep.addColorStop(0.5, `rgba(255,255,255,${0.016 * drive})`);\n sweep.addColorStop(1, \"rgba(255,255,255,0)\");\n context.fillStyle = sweep;\n context.fillRect(0, 0, w, h);\n\n // The corners of the glass.\n const vignette = context.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.32, w / 2, h / 2, Math.max(w, h) * 0.72);\n vignette.addColorStop(0, \"rgba(0,0,0,0)\");\n vignette.addColorStop(1, \"rgba(0,0,0,0.82)\");\n context.fillStyle = vignette;\n context.fillRect(0, 0, w, h);\n },\n [history, command, response, prompt, phosphor, intensity, charactersPerSecond],\n );\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#000000\", justifyContent: \"center\"}}>\n <canvas ref={canvas} width={width} height={height} style={{height: \"100%\", width: \"100%\"}} />\n </Fill>\n );\n};\n",
1279
+ "target": "videos/components/crt-terminal/crt-terminal.tsx"
1280
+ },
1281
+ {
1282
+ "path": "components/crt-terminal/crt-terminal.preview.tsx",
1283
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CrtTerminal} from \"./crt-terminal\";\n\nexport default defineComponentPreview({\n title: \"CRT terminal\",\n category: \"Media/Treatments\",\n description: \"A terminal on a cathode ray tube: bowed glass, unconverged beam, phosphor bloom and a rolling shadow mask.\",\n component: CrtTerminal,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n command: {type: \"text\", defaultValue: \"render launch --format mp4\", maxLength: 60},\n prompt: {type: \"text\", defaultValue: \">\", maxLength: 8},\n phosphor: {type: \"select\", defaultValue: \"green\", options: [\"green\", \"amber\", \"white\"]},\n intensity: {type: \"number\", defaultValue: 0.6, min: 0, max: 1, step: 0.05},\n charactersPerSecond: {type: \"number\", defaultValue: 16, min: 4, max: 40, step: 1},\n },\n examples: [\n {name: \"P1 green\", props: {}},\n {name: \"Amber\", props: {phosphor: \"amber\", intensity: 0.75}},\n {\n name: \"Cold boot\",\n props: {\n phosphor: \"white\",\n intensity: 0.35,\n history: [\"MEMORY OK\", \"LOADING ODORI…\"],\n command: \"boot\",\n response: [\"RUNTIME READY\", \"STUDIO ON 127.0.0.1:4300\"],\n },\n },\n ],\n});\n",
1284
+ "target": "videos/components/crt-terminal/crt-terminal.preview.tsx"
1285
+ }
1286
+ ],
1287
+ "meta": {
1288
+ "kind": "component",
1289
+ "family": "Media",
1290
+ "namespaced": "@odori/crt-terminal",
1291
+ "contract": {
1292
+ "aspectRatios": [
1293
+ "16:9",
1294
+ "9:16",
1295
+ "1:1"
938
1296
  ],
939
- "recommendedDurationInFrames": 150,
940
- "minimumDurationInFrames": 75,
941
- "entranceFrames": 26,
1297
+ "recommendedDurationInFrames": 270,
1298
+ "minimumDurationInFrames": 120,
1299
+ "entranceFrames": 12,
942
1300
  "exitFrames": 10,
943
1301
  "contentLimits": {
944
- "nodes": 4,
945
- "label": 18
1302
+ "command": 60,
1303
+ "prompt": 8
946
1304
  },
947
- "reducedMotion": "connects without sweeping links",
1305
+ "reducedMotion": "no flicker, roll or sweep; text held",
948
1306
  "requires": {
949
1307
  "fonts": [
950
- "sans"
1308
+ "sans",
1309
+ "mono"
951
1310
  ],
952
1311
  "audio": []
953
1312
  }
@@ -966,13 +1325,13 @@
966
1325
  },
967
1326
  {
968
1327
  "path": "components/cursor-focus/cursor-focus.preview.tsx",
969
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CursorFocus} from \"./cursor-focus\";\n\n/** A plain surface, so the fixture shows the pointer rather than a product. */\nconst Surface = () => (\n <div style={{background: \"#0b0b0b\", inset: 0, position: \"absolute\"}}>\n {[\n {label: \"Overview\", top: 220},\n {label: \"Deployments\", top: 340},\n {label: \"Settings\", top: 460},\n ].map((row) => (\n <div\n key={row.label}\n style={{\n border: \"1px solid #1f1f1f\",\n borderRadius: 16,\n color: \"#d4d4d4\",\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 40,\n left: 260,\n padding: \"28px 40px\",\n position: \"absolute\",\n top: row.top,\n width: 900,\n }}\n >\n {row.label}\n </div>\n ))}\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Cursor focus\",\n category: \"Product UI\",\n description: \"An authored pointer that moves, holds, and clicks over a surface.\",\n component: CursorFocus,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n size: {type: \"number\", defaultValue: 28, min: 16, max: 64},\n spotlight: {type: \"boolean\", defaultValue: false},\n },\n examples: [\n {\n name: \"Default\",\n props: {\n children: <Surface />,\n path: [\n {frame: 0, x: 1500, y: 900},\n {frame: 30, x: 640, y: 380, click: true, hold: 20},\n {frame: 60, x: 700, y: 500, click: true, hold: 15},\n ],\n },\n },\n {\n name: \"Spotlight\",\n props: {\n children: <Surface />,\n spotlight: true,\n path: [\n {frame: 0, x: 1600, y: 300},\n {frame: 36, x: 700, y: 620, click: true, hold: 30},\n ],\n },\n },\n ],\n});\n",
1328
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CursorFocus} from \"./cursor-focus\";\n\n/** A plain surface, so the fixture shows the pointer rather than a product. */\nconst Surface = () => (\n <div style={{background: \"#0b0b0b\", inset: 0, position: \"absolute\"}}>\n {[\n {label: \"Overview\", top: 220},\n {label: \"Deployments\", top: 340},\n {label: \"Settings\", top: 460},\n ].map((row) => (\n <div\n key={row.label}\n style={{\n border: \"1px solid #1f1f1f\",\n borderRadius: 16,\n color: \"#d4d4d4\",\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 40,\n left: 260,\n padding: \"28px 40px\",\n position: \"absolute\",\n top: row.top,\n width: 900,\n }}\n >\n {row.label}\n </div>\n ))}\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Cursor focus\",\n category: \"Interface/Controls\",\n description: \"An authored pointer that moves, holds, and clicks over a surface.\",\n component: CursorFocus,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n size: {type: \"number\", defaultValue: 28, min: 16, max: 64},\n spotlight: {type: \"boolean\", defaultValue: false},\n },\n examples: [\n {\n name: \"Default\",\n props: {\n children: <Surface />,\n path: [\n {frame: 0, x: 1500, y: 900},\n {frame: 30, x: 640, y: 380, click: true, hold: 20},\n {frame: 60, x: 700, y: 500, click: true, hold: 15},\n ],\n },\n },\n {\n name: \"Spotlight\",\n props: {\n children: <Surface />,\n spotlight: true,\n path: [\n {frame: 0, x: 1600, y: 300},\n {frame: 36, x: 700, y: 620, click: true, hold: 30},\n ],\n },\n },\n ],\n});\n",
970
1329
  "target": "videos/components/cursor-focus/cursor-focus.preview.tsx"
971
1330
  }
972
1331
  ],
973
1332
  "meta": {
974
1333
  "kind": "component",
975
- "family": "Product UI",
1334
+ "family": "Interface",
976
1335
  "namespaced": "@odori/cursor-focus",
977
1336
  "contract": {
978
1337
  "aspectRatios": [
@@ -1007,13 +1366,13 @@
1007
1366
  },
1008
1367
  {
1009
1368
  "path": "components/dashboard-frame/dashboard-frame.preview.tsx",
1010
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DashboardFrame} from \"./dashboard-frame\";\n\nconst Rows = () => (\n <div style={{display: \"flex\", flexDirection: \"column\", gap: 16}}>\n {[\n [\"odori.dev\", \"Production\", \"12s ago\"],\n [\"docs-site\", \"Preview\", \"4m ago\"],\n [\"studio\", \"Production\", \"1h ago\"],\n ].map(([name, environment, when]) => (\n <div\n key={name}\n style={{\n alignItems: \"center\",\n border: \"1px solid #1f1f1f\",\n borderRadius: 14,\n color: \"#e5e5e5\",\n display: \"flex\",\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 26,\n justifyContent: \"space-between\",\n padding: \"22px 26px\",\n }}\n >\n <span>{name}</span>\n <span style={{color: \"#8f8f8f\"}}>{environment}</span>\n <span style={{color: \"#8f8f8f\", fontFamily: \"ui-monospace, monospace\"}}>{when}</span>\n </div>\n ))}\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Dashboard frame\",\n category: \"Product UI\",\n description: \"Navigation, content, and status regions for a SaaS surface.\",\n component: DashboardFrame,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n product: {type: \"text\", defaultValue: \"Acme\"},\n title: {type: \"text\", defaultValue: \"Deployments\"},\n status: {type: \"text\", defaultValue: \"All systems normal\"},\n active: {type: \"number\", defaultValue: 1, min: 0, max: 3},\n },\n examples: [\n {name: \"Default\", props: {children: <Rows />}},\n {name: \"Settings\", props: {active: 3, title: \"Settings\", status: undefined, children: <Rows />}},\n ],\n});\n",
1369
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DashboardFrame} from \"./dashboard-frame\";\n\nconst Rows = () => (\n <div style={{display: \"flex\", flexDirection: \"column\", gap: 16}}>\n {[\n [\"odori.dev\", \"Production\", \"12s ago\"],\n [\"docs-site\", \"Preview\", \"4m ago\"],\n [\"studio\", \"Production\", \"1h ago\"],\n ].map(([name, environment, when]) => (\n <div\n key={name}\n style={{\n alignItems: \"center\",\n border: \"1px solid #1f1f1f\",\n borderRadius: 14,\n color: \"#e5e5e5\",\n display: \"flex\",\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 26,\n justifyContent: \"space-between\",\n padding: \"22px 26px\",\n }}\n >\n <span>{name}</span>\n <span style={{color: \"#8f8f8f\"}}>{environment}</span>\n <span style={{color: \"#8f8f8f\", fontFamily: \"ui-monospace, monospace\"}}>{when}</span>\n </div>\n ))}\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Dashboard frame\",\n category: \"Interface/Surfaces\",\n description: \"Navigation, content, and status regions for a SaaS surface.\",\n component: DashboardFrame,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n product: {type: \"text\", defaultValue: \"Acme\"},\n title: {type: \"text\", defaultValue: \"Deployments\"},\n status: {type: \"text\", defaultValue: \"All systems normal\"},\n active: {type: \"number\", defaultValue: 1, min: 0, max: 3},\n },\n examples: [\n {name: \"Default\", props: {children: <Rows />}},\n {name: \"Settings\", props: {active: 3, title: \"Settings\", status: undefined, children: <Rows />}},\n ],\n});\n",
1011
1370
  "target": "videos/components/dashboard-frame/dashboard-frame.preview.tsx"
1012
1371
  }
1013
1372
  ],
1014
1373
  "meta": {
1015
1374
  "kind": "component",
1016
- "family": "Product UI",
1375
+ "family": "Interface",
1017
1376
  "namespaced": "@odori/dashboard-frame",
1018
1377
  "contract": {
1019
1378
  "aspectRatios": [
@@ -1084,6 +1443,49 @@
1084
1443
  }
1085
1444
  }
1086
1445
  },
1446
+ {
1447
+ "name": "date-picker",
1448
+ "description": "A calendar where a range fills outwards from the date chosen first.",
1449
+ "registryDependencies": [],
1450
+ "files": [
1451
+ {
1452
+ "path": "components/date-picker/date-picker.tsx",
1453
+ "content": "import {Easing, Fill, interpolate, spring, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\nexport type DatePickerProps = {\n /** The month heading. */\n month?: string;\n /** Weekday of the first, 0 for Monday. */\n startsOn?: number;\n /** How many days the month has. */\n days?: number;\n /** The day that gets picked. */\n picks?: number;\n /** A second day, for a range. Zero for a single date. */\n through?: number;\n /** The label above the calendar. */\n label?: string;\n openAt?: number;\n};\n\nconst WEEK = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"];\n\n/**\n * A calendar with a date being chosen.\n *\n * A range fills between its ends rather than appearing whole, so the second\n * click is seen to reach back to the first. Without that the range is just a\n * differently coloured block and nobody can tell which end was chosen first.\n */\nexport const DatePicker = ({\n month = \"August 2026\",\n startsOn = 5,\n days = 31,\n picks = 12,\n through = 19,\n label = \"Release window\",\n openAt = 20,\n}: DatePickerProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const opened = spring({frame, fps, delayInFrames: openAt, stiffness: 210, damping: 18});\n const first = interpolate(frame, [openAt + 22, openAt + 30], [0, 1], {easing: Easing.standard});\n // The range fills from the first date outwards to the second.\n const reach = interpolate(frame, [openAt + 40, openAt + 62], [0, 1], {easing: Easing.standard});\n const edge = through > picks ? picks + (through - picks) * reach : picks;\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#09090B\", justifyContent: \"center\", padding: px(120)}}>\n <div style={{fontFamily: brand.typography.sans, opacity: enter}}>\n <div style={{color: \"#8A8A93\", fontSize: px(20), marginBottom: px(12)}}>{label}</div>\n <div\n style={{\n background: \"#0C0C0E\",\n border: `${px(1)}px solid #1F1F23`,\n borderRadius: px(14),\n opacity: opened,\n padding: px(30),\n transform: `scale(${0.96 + opened * 0.04})`,\n }}\n >\n <div style={{color: \"#FAFAFA\", fontSize: px(24), fontWeight: 500, marginBottom: px(22), textAlign: \"center\"}}>\n {month}\n </div>\n <div style={{display: \"grid\", gap: px(6), gridTemplateColumns: \"repeat(7, 1fr)\"}}>\n {WEEK.map((day) => (\n <span key={day} style={{color: \"#6E6E78\", fontSize: px(17), padding: px(8), textAlign: \"center\"}}>\n {day}\n </span>\n ))}\n {Array.from({length: startsOn}, (_, index) => (\n <span key={`pad-${index}`} />\n ))}\n {Array.from({length: days}, (_, index) => {\n const day = index + 1;\n const inRange = through > picks && day > picks && day <= edge;\n const isStart = day === picks;\n const isEnd = through > picks && day === through && reach >= 1;\n const on = isStart ? first : isEnd ? 1 : inRange ? 1 : 0;\n return (\n <span\n key={day}\n style={{\n background: isStart || isEnd ? `color-mix(in srgb, #FAFAFA ${on * 100}%, transparent)` : inRange ? \"#1E1E24\" : \"transparent\",\n borderRadius: px(8),\n color: isStart || isEnd ? (on > 0.5 ? \"#09090B\" : \"#FAFAFA\") : \"#C8C8CE\",\n fontSize: px(21),\n padding: `${px(12)}px 0`,\n textAlign: \"center\",\n transform: `scale(${isStart ? 0.9 + first * 0.1 : 1})`,\n width: px(58),\n }}\n >\n {day}\n </span>\n );\n })}\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
1454
+ "target": "videos/components/date-picker/date-picker.tsx"
1455
+ },
1456
+ {
1457
+ "path": "components/date-picker/date-picker.preview.tsx",
1458
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DatePicker} from \"./date-picker\";\n\nexport default defineComponentPreview({\n title: \"Date picker\",\n category: \"Interface/Controls\",\n description: \"A calendar where a range fills outwards from the date chosen first.\",\n component: DatePicker,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n month: {type: \"text\", defaultValue: \"August 2026\", maxLength: 24},\n picks: {type: \"number\", defaultValue: 12, min: 1, max: 28, step: 1},\n through: {type: \"number\", defaultValue: 19, min: 0, max: 31, step: 1},\n label: {type: \"text\", defaultValue: \"Release window\", maxLength: 28},\n },\n examples: [\n {name: \"A range\", props: {}},\n {name: \"One date\", props: {through: 0, label: \"Ship date\"}},\n ],\n});\n",
1459
+ "target": "videos/components/date-picker/date-picker.preview.tsx"
1460
+ }
1461
+ ],
1462
+ "meta": {
1463
+ "kind": "component",
1464
+ "family": "Interface",
1465
+ "namespaced": "@odori/date-picker",
1466
+ "contract": {
1467
+ "aspectRatios": [
1468
+ "16:9",
1469
+ "1:1"
1470
+ ],
1471
+ "recommendedDurationInFrames": 180,
1472
+ "minimumDurationInFrames": 75,
1473
+ "entranceFrames": 14,
1474
+ "exitFrames": 10,
1475
+ "contentLimits": {
1476
+ "month": 24,
1477
+ "label": 28
1478
+ },
1479
+ "reducedMotion": "range shown filled, no reach",
1480
+ "requires": {
1481
+ "fonts": [
1482
+ "sans"
1483
+ ],
1484
+ "audio": []
1485
+ }
1486
+ }
1487
+ }
1488
+ },
1087
1489
  {
1088
1490
  "name": "dependency-graph",
1089
1491
  "description": "Packages and modules connected as a readable graph.",
@@ -1096,7 +1498,7 @@
1096
1498
  },
1097
1499
  {
1098
1500
  "path": "components/dependency-graph/dependency-graph.preview.tsx",
1099
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DependencyGraph} from \"./dependency-graph\";\n\nexport default defineComponentPreview({\n title: \"Dependency graph\",\n category: \"Developer proof\",\n description: \"Packages and modules connected as a readable graph.\",\n component: DependencyGraph,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n headline: {type: \"text\", defaultValue: \"One graph, one render path.\"},\n },\n examples: [\n {\n name: \"Project\",\n props: {\n nodes: [\n {label: \"videos/launch\", x: 0.24, y: 0.4},\n {label: \"videos/components\", x: 0.56, y: 0.26},\n {label: \"odori\", x: 0.78, y: 0.56},\n {label: \"public/audio\", x: 0.36, y: 0.74},\n ],\n edges: [\n [0, 1],\n [1, 2],\n [0, 3],\n ],\n },\n },\n ],\n});\n",
1501
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DependencyGraph} from \"./dependency-graph\";\n\nexport default defineComponentPreview({\n title: \"Dependency graph\",\n category: \"Developer proof/Code\",\n description: \"Packages and modules connected as a readable graph.\",\n component: DependencyGraph,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n headline: {type: \"text\", defaultValue: \"One graph, one render path.\"},\n },\n examples: [\n {\n name: \"Project\",\n props: {\n nodes: [\n {label: \"videos/launch\", x: 0.24, y: 0.4},\n {label: \"videos/components\", x: 0.56, y: 0.26},\n {label: \"odori\", x: 0.78, y: 0.56},\n {label: \"public/audio\", x: 0.36, y: 0.74},\n ],\n edges: [\n [0, 1],\n [1, 2],\n [0, 3],\n ],\n },\n },\n ],\n});\n",
1100
1502
  "target": "videos/components/dependency-graph/dependency-graph.preview.tsx"
1101
1503
  }
1102
1504
  ],
@@ -1140,13 +1542,13 @@
1140
1542
  },
1141
1543
  {
1142
1544
  "path": "components/device-frame/device-frame.preview.tsx",
1143
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DeviceFrame} from \"./device-frame\";\n\nconst Screen = () => (\n <div\n style={{\n alignItems: \"center\",\n color: \"#f5f5f5\",\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: \"ui-sans-serif, system-ui\",\n gap: 18,\n inset: 0,\n justifyContent: \"center\",\n position: \"absolute\",\n textAlign: \"center\",\n }}\n >\n <strong style={{fontSize: 54, letterSpacing: \"-0.03em\"}}>Deployments</strong>\n <span style={{color: \"#8f8f8f\", fontSize: 28}}>All systems normal</span>\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Device frame\",\n category: \"Product UI\",\n description: \"A phone, tablet, or desktop body around a product surface.\",\n component: DeviceFrame,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n device: {type: \"select\", defaultValue: \"desktop\", options: [\"desktop\", \"tablet\", \"phone\"]},\n url: {type: \"text\", defaultValue: \"app.example.com/deployments\"},\n entranceFrames: {type: \"number\", defaultValue: 20, min: 0, max: 60},\n },\n examples: [\n {name: \"Desktop\", props: {children: <Screen />}},\n {name: \"Phone\", props: {device: \"phone\", children: <Screen />}},\n {name: \"Tablet\", props: {device: \"tablet\", children: <Screen />}},\n ],\n});\n",
1545
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DeviceFrame} from \"./device-frame\";\n\nconst Screen = () => (\n <div\n style={{\n alignItems: \"center\",\n color: \"#f5f5f5\",\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: \"ui-sans-serif, system-ui\",\n gap: 18,\n inset: 0,\n justifyContent: \"center\",\n position: \"absolute\",\n textAlign: \"center\",\n }}\n >\n <strong style={{fontSize: 54, letterSpacing: \"-0.03em\"}}>Deployments</strong>\n <span style={{color: \"#8f8f8f\", fontSize: 28}}>All systems normal</span>\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Device frame\",\n category: \"Interface/Chrome\",\n description: \"A phone, tablet, or desktop body around a product surface.\",\n component: DeviceFrame,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n device: {type: \"select\", defaultValue: \"desktop\", options: [\"desktop\", \"tablet\", \"phone\"]},\n url: {type: \"text\", defaultValue: \"app.example.com/deployments\"},\n entranceFrames: {type: \"number\", defaultValue: 20, min: 0, max: 60},\n },\n examples: [\n {name: \"Desktop\", props: {children: <Screen />}},\n {name: \"Phone\", props: {device: \"phone\", children: <Screen />}},\n {name: \"Tablet\", props: {device: \"tablet\", children: <Screen />}},\n ],\n});\n",
1144
1546
  "target": "videos/components/device-frame/device-frame.preview.tsx"
1145
1547
  }
1146
1548
  ],
1147
1549
  "meta": {
1148
1550
  "kind": "component",
1149
- "family": "Product UI",
1551
+ "family": "Interface",
1150
1552
  "namespaced": "@odori/device-frame",
1151
1553
  "contract": {
1152
1554
  "aspectRatios": [
@@ -1183,7 +1585,7 @@
1183
1585
  },
1184
1586
  {
1185
1587
  "path": "components/diff-proof/diff-proof.preview.tsx",
1186
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DiffProof} from \"./diff-proof\";\n\nexport default defineComponentPreview({\n title: \"Diff proof\",\n category: \"Developer proof\",\n description: \"Before and after source changes with semantic emphasis.\",\n component: DiffProof,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"videos/launch/video.tsx\"},\n stagger: {type: \"number\", defaultValue: 8, min: 2, max: 24},\n },\n examples: [\n {\n name: \"Scene retimed\",\n props: {\n lines: [\n {text: \"<Video>\", kind: \"context\"},\n {text: ' <Scene duration=\"4s\">', kind: \"removed\"},\n {text: ' <Scene duration=\"5s\">', kind: \"added\"},\n {text: \" <TitleReveal title={headline} />\", kind: \"context\"},\n {text: \" </Scene>\", kind: \"context\"},\n {text: \"</Video>\", kind: \"context\"},\n ],\n },\n },\n ],\n});\n",
1588
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DiffProof} from \"./diff-proof\";\n\nexport default defineComponentPreview({\n title: \"Diff proof\",\n category: \"Developer proof/Code\",\n description: \"Before and after source changes with semantic emphasis.\",\n component: DiffProof,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"videos/launch/video.tsx\"},\n stagger: {type: \"number\", defaultValue: 8, min: 2, max: 24},\n },\n examples: [\n {\n name: \"Scene retimed\",\n props: {\n lines: [\n {text: \"<Video>\", kind: \"context\"},\n {text: ' <Scene duration=\"4s\">', kind: \"removed\"},\n {text: ' <Scene duration=\"5s\">', kind: \"added\"},\n {text: \" <TitleReveal title={headline} />\", kind: \"context\"},\n {text: \" </Scene>\", kind: \"context\"},\n {text: \"</Video>\", kind: \"context\"},\n ],\n },\n },\n ],\n});\n",
1187
1589
  "target": "videos/components/diff-proof/diff-proof.preview.tsx"
1188
1590
  }
1189
1591
  ],
@@ -1214,6 +1616,49 @@
1214
1616
  }
1215
1617
  }
1216
1618
  },
1619
+ {
1620
+ "name": "discord",
1621
+ "description": "A Discord server where a bot answers in a channel, quoting the message it replies to.",
1622
+ "registryDependencies": [],
1623
+ "files": [
1624
+ {
1625
+ "path": "components/discord/discord.tsx",
1626
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type DiscordPost = {\n author: string;\n body: string;\n time?: string;\n /** Marks the sender as an application. */\n app?: boolean;\n /** The author's role colour, the way a server colours a name. */\n color?: string;\n};\n\nexport type DiscordProps = {\n /** The server name over the channel list. */\n server?: string;\n /** Channels under the text category. */\n channels?: string[];\n /** The channel being read. */\n channel?: string;\n /** The channel topic, beside the name in the header. */\n topic?: string;\n /** Messages already in the channel. */\n history?: DiscordPost[];\n /** The message that gets answered. */\n ask?: DiscordPost;\n /** The bot's reply. It quotes the message it answers. */\n reply?: DiscordPost & {to?: string};\n /** Names in the members list. Hidden when empty. */\n members?: string[];\n thinkingFrames?: number;\n charactersPerSecond?: number;\n};\n\n/** Discord's dark tokens, the same set the desktop and mobile apps share. */\n/** Discord's shipped dark theme, read from the client. */\nconst RAIL = \"#121214\";\nconst SIDEBAR = \"#1A1A1E\";\nconst CHAT = \"#202024\";\nconst COMPOSER = \"#2B2B31\";\nconst ACTIVE = \"#393A41\";\nconst INK = \"#EFEFF1\";\nconst STRONG = \"#FBFBFB\";\nconst MUTED = \"#96979E\";\nconst META = \"#96979E\";\nconst BLURPLE = \"#5865F2\";\nconst FONT = '\"gg sans\", \"Noto Sans\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\nconst ROLES = [\"#5865F2\", \"#EB459E\", \"#57F287\", \"#FEE75C\"];\n\n/**\n * A Discord server with a bot answering in a channel.\n *\n * Discord is three columns and the whole app is the recognition: the server\n * rail, the channel list with one channel lit, and the members list on the\n * right. The reply quotes the message it answers, which is the detail that\n * lets a single frame carry both the question and the answer without a cut.\n */\nexport const Discord = ({\n server = \"Odori\",\n channels = [\"general\", \"support\", \"releases\", \"showcase\"],\n channel = \"support\",\n topic = \"Render questions and bug reports\",\n history = [{author: \"janedoe\", body: \"nightly finished, artifacts are up\", time: \"9:31 AM\", color: ROLES[2]}],\n ask = {author: \"johndoe\", body: \"the nightly render is 40MB heavier than yesterday, anyone seen that?\", time: \"9:38 AM\"},\n reply = {\n author: \"Odori\",\n app: true,\n to: \"johndoe\",\n body: \"Same commit. Yesterday reused cached chunks; today re-encoded at studio quality after the toolchain pin.\",\n time: \"9:39 AM\",\n color: BLURPLE,\n },\n members = [\"Odori\", \"johndoe\", \"janedoe\", \"samdoe\"],\n thinkingFrames = 30,\n charactersPerSecond = 34,\n}: DiscordProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n /* The typing is the person's, not the bot's: they compose in the box and\n press send, and the bot's answer is simply there when it arrives. */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(ask.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 20;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(ask.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const working = frame >= workingAt && frame < replyAt;\n\n /**\n * Discord's proportions, not approximations of them: the avatar is 2.5x\n * the message text and the gutter matches it, which is what makes the\n * avatar hang past the name into the first line rather than sitting\n * centred on the name.\n */\n /**\n * Discord's default avatar is a silhouette on a flat colour, never an\n * initial. An app keeps its own mark, which is how the eye separates the\n * bot from the people in one glance.\n */\n const avatar = (name: string, color: string | undefined, size = 52, app = false) => (\n <span\n style={{\n alignItems: \"center\",\n background: color ?? ROLES[name.length % ROLES.length],\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(size * 0.42),\n fontWeight: 700,\n height: px(size),\n justifyContent: \"center\",\n width: px(size),\n }}\n >\n {app ? (\n name.slice(0, 1).toUpperCase()\n ) : (\n /* The silhouette Discord draws for an account with no picture:\n a head and shoulders, centred, at about half the circle. */\n <svg viewBox=\"0 0 24 24\" width={px(size * 0.62)} height={px(size * 0.62)} fill=\"#FFFFFF\">\n <circle cx=\"12\" cy=\"9\" r=\"4\" />\n <path d=\"M4.6 20a7.6 7.6 0 0 1 14.8 0z\" />\n </svg>\n )}\n </span>\n );\n\n const post = (item: DiscordPost, landed: number, body: ReactNode, key?: string) => (\n <div\n key={key}\n style={{\n display: \"flex\",\n gap: px(21),\n opacity: landed,\n padding: `${px(9)}px ${px(22)}px`,\n transform: `translateY(${(1 - landed) * px(8)}px)`,\n }}\n >\n {avatar(item.author, item.color, 52, item.app)}\n <div style={{minWidth: 0}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(10)}}>\n <span style={{color: item.color ?? ROLES[item.author.length % ROLES.length], fontSize: px(21), fontWeight: 600}}>\n {item.author}\n </span>\n {item.app ? (\n <span\n style={{\n background: BLURPLE,\n borderRadius: px(4),\n color: \"#FFFFFF\",\n fontSize: px(13),\n fontWeight: 600,\n padding: `${px(1)}px ${px(6)}px`,\n }}\n >\n APP\n </span>\n ) : null}\n {item.time ? <span style={{color: MUTED, fontSize: px(16)}}>{item.time}</span> : null}\n </div>\n <div style={{color: INK, fontSize: px(21), lineHeight: 1.45, marginTop: px(3)}}>{body}</div>\n </div>\n </div>\n );\n\n const dots = (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(7), height: px(26)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: MUTED,\n borderRadius: px(999),\n height: px(9),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(9),\n }}\n />\n ))}\n </span>\n );\n\n return (\n <Fill style={{background: \"#000000\", padding: px(60)}}>\n <div\n style={{\n borderRadius: px(14),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The server rail. */}\n <div\n style={{\n alignItems: \"center\",\n background: RAIL,\n display: \"flex\",\n flex: \"none\",\n flexDirection: \"column\",\n gap: px(14),\n padding: `${px(16)}px 0`,\n width: px(84),\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: BLURPLE,\n borderRadius: px(18),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(22),\n fontWeight: 700,\n height: px(52),\n justifyContent: \"center\",\n width: px(52),\n }}\n >\n {server.slice(0, 1).toUpperCase()}\n </span>\n <span style={{background: \"#35373C\", height: px(2), width: px(34)}} />\n {[0, 1].map((item) => (\n <span key={item} style={{background: SIDEBAR, borderRadius: px(999), height: px(52), width: px(52)}} />\n ))}\n </div>\n\n {/* The channel list. */}\n <div style={{background: SIDEBAR, flex: \"none\", width: px(300)}}>\n <div\n style={{\n borderBottom: `${px(1)}px solid ${RAIL}`,\n color: STRONG,\n fontSize: px(21),\n fontWeight: 700,\n padding: `${px(20)}px ${px(20)}px`,\n }}\n >\n {server}\n </div>\n <div\n style={{\n color: MUTED,\n fontSize: px(14),\n fontWeight: 700,\n letterSpacing: \"0.04em\",\n padding: `${px(20)}px ${px(20)}px ${px(8)}px`,\n }}\n >\n TEXT CHANNELS\n </div>\n {channels.map((name) => (\n <div\n key={name}\n style={{\n alignItems: \"center\",\n background: name === channel ? ACTIVE : \"transparent\",\n borderRadius: px(6),\n color: name === channel ? STRONG : MUTED,\n display: \"flex\",\n fontSize: px(19),\n fontWeight: name === channel ? 600 : 400,\n gap: px(8),\n margin: `${px(2)}px ${px(10)}px`,\n padding: `${px(8)}px ${px(10)}px`,\n }}\n >\n <span style={{color: MUTED, fontSize: px(21)}}>#</span>\n {name}\n </div>\n ))}\n </div>\n\n {/* The channel. */}\n <div style={{background: CHAT, display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div\n style={{\n alignItems: \"center\",\n borderBottom: `${px(1)}px solid rgba(0,0,0,0.2)`,\n display: \"flex\",\n gap: px(14),\n padding: `${px(18)}px ${px(22)}px`,\n }}\n >\n <span style={{color: MUTED, fontSize: px(24)}}>#</span>\n <span style={{color: STRONG, fontSize: px(22), fontWeight: 700}}>{channel}</span>\n <span style={{background: \"#3F4147\", height: px(22), width: px(1)}} />\n <span style={{color: MUTED, fontSize: px(17)}}>{topic}</span>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", justifyContent: \"flex-end\", paddingBottom: px(10)}}>\n {history.map((item, index) => post(item, 1, item.body, `${item.author}-${index}`))}\n {post(ask, asked, ask.body)}\n\n {frame >= workingAt ? (\n <div style={{opacity: working ? 1 : replied}}>\n {reply.to && !working ? (\n <div\n style={{\n alignItems: \"center\",\n color: MUTED,\n display: \"flex\",\n fontSize: px(16),\n gap: px(8),\n paddingLeft: px(73),\n }}\n >\n <span\n style={{\n borderLeft: `${px(2)}px solid #4E5058`,\n borderTop: `${px(2)}px solid #4E5058`,\n borderTopLeftRadius: px(8),\n height: px(10),\n marginRight: px(4),\n width: px(24),\n }}\n />\n {avatar(reply.to, ask.color, 20)}\n <span style={{color: ROLES[0]}}>@{reply.to}</span>\n <span style={{overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\"}}>{ask.body}</span>\n </div>\n ) : null}\n {post(\n reply,\n 1,\n working ? dots : reply.body,\n )}\n </div>\n ) : null}\n </div>\n\n {/* The composer, with the controls Discord keeps in it: a plus on\n the left for uploads, and the gift, GIF and emoji row on the\n right. Without them the box reads as a text field, not Discord. */}\n <div\n style={{\n alignItems: \"center\",\n background: COMPOSER,\n borderRadius: px(10),\n display: \"flex\",\n gap: px(16),\n margin: `0 ${px(22)}px ${px(20)}px`,\n padding: `${px(12)}px ${px(18)}px`,\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: MUTED,\n borderRadius: px(999),\n color: COMPOSER,\n display: \"flex\",\n flex: \"none\",\n fontSize: px(22),\n height: px(26),\n justifyContent: \"center\",\n width: px(26),\n }}\n >\n +\n </span>\n <span style={{color: sent ? MUTED : INK, flex: 1, fontSize: px(19), minWidth: 0}}>\n {sent ? (\n `Message #${channel}`\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: INK,\n display: \"inline-block\",\n height: px(20),\n marginLeft: px(2),\n transform: `translateY(${px(3)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </span>\n <span style={{alignItems: \"center\", color: MUTED, display: \"flex\", flex: \"none\", gap: px(14)}}>\n {/* Gift. */}\n <svg viewBox=\"0 0 24 24\" width={px(22)} height={px(22)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8}>\n <path d=\"M4 11h16v9H4zM3 7.5h18V11H3zM12 7.5V20\" strokeLinejoin=\"round\" />\n <path d=\"M12 7.5S10.5 4 8.5 4a2 2 0 0 0 0 3.5zM12 7.5S13.5 4 15.5 4a2 2 0 0 1 0 3.5z\" strokeLinejoin=\"round\" />\n </svg>\n {/* GIF. */}\n <span\n style={{\n border: `${px(1.6)}px solid currentColor`,\n borderRadius: px(4),\n fontSize: px(12),\n fontWeight: 700,\n letterSpacing: \"0.02em\",\n padding: `${px(1)}px ${px(4)}px`,\n }}\n >\n GIF\n </span>\n {/* Emoji. */}\n <svg viewBox=\"0 0 24 24\" width={px(22)} height={px(22)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8}>\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <circle cx=\"9\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <circle cx=\"15\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <path d=\"M8.5 14.5a4.5 4.5 0 0 0 7 0\" strokeLinecap=\"round\" />\n </svg>\n </span>\n </div>\n </div>\n\n {/* The members list. */}\n {members.length > 0 ? (\n <div style={{background: SIDEBAR, flex: \"none\", padding: `${px(20)}px ${px(16)}px`, width: px(260)}}>\n <div style={{color: MUTED, fontSize: px(14), fontWeight: 700, letterSpacing: \"0.04em\", paddingLeft: px(8)}}>\n ONLINE — {members.length}\n </div>\n {members.map((name, index) => (\n <div\n key={name}\n style={{alignItems: \"center\", display: \"flex\", gap: px(12), padding: `${px(9)}px ${px(8)}px`}}\n >\n {avatar(name, index === 0 ? BLURPLE : ROLES[name.length % ROLES.length], 36, index === 0)}\n <span style={{color: index === 0 ? BLURPLE : META, fontSize: px(19)}}>{name}</span>\n {index === 0 ? (\n <span\n style={{\n background: BLURPLE,\n borderRadius: px(4),\n color: \"#FFFFFF\",\n fontSize: px(12),\n fontWeight: 600,\n padding: `${px(1)}px ${px(5)}px`,\n }}\n >\n APP\n </span>\n ) : null}\n </div>\n ))}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
1627
+ "target": "videos/components/discord/discord.tsx"
1628
+ },
1629
+ {
1630
+ "path": "components/discord/discord.preview.tsx",
1631
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Discord} from \"./discord\";\n\nexport default defineComponentPreview({\n title: \"Discord\",\n category: \"Products\",\n description: \"A Discord server where a bot answers in a channel, quoting the message it replies to.\",\n component: Discord,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n server: {type: \"text\", defaultValue: \"Odori\", maxLength: 24},\n channel: {type: \"text\", defaultValue: \"support\", maxLength: 24},\n topic: {type: \"text\", defaultValue: \"Render questions and bug reports\", maxLength: 60},\n thinkingFrames: {type: \"number\", defaultValue: 30, min: 0, max: 90, step: 2},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"No members list\", props: {members: []}},\n ],\n});\n",
1632
+ "target": "videos/components/discord/discord.preview.tsx"
1633
+ }
1634
+ ],
1635
+ "meta": {
1636
+ "kind": "component",
1637
+ "family": "Products",
1638
+ "namespaced": "@odori/discord",
1639
+ "contract": {
1640
+ "aspectRatios": [
1641
+ "16:9"
1642
+ ],
1643
+ "recommendedDurationInFrames": 270,
1644
+ "minimumDurationInFrames": 150,
1645
+ "entranceFrames": 14,
1646
+ "exitFrames": 12,
1647
+ "contentLimits": {
1648
+ "server": 24,
1649
+ "channel": 24,
1650
+ "topic": 60
1651
+ },
1652
+ "reducedMotion": "messages placed, no working indicator",
1653
+ "requires": {
1654
+ "fonts": [
1655
+ "sans"
1656
+ ],
1657
+ "audio": []
1658
+ }
1659
+ }
1660
+ }
1661
+ },
1217
1662
  {
1218
1663
  "name": "donut-chart",
1219
1664
  "description": "Part-to-whole display with a stable center metric.",
@@ -1258,6 +1703,48 @@
1258
1703
  }
1259
1704
  }
1260
1705
  },
1706
+ {
1707
+ "name": "dropdown-menu",
1708
+ "description": "A menu opening from its trigger, the highlight walking to the item chosen.",
1709
+ "registryDependencies": [],
1710
+ "files": [
1711
+ {
1712
+ "path": "components/dropdown-menu/dropdown-menu.tsx",
1713
+ "content": "import {Easing, Fill, interpolate, spring, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\nexport type DropdownMenuProps = {\n /** The control the menu hangs from. */\n trigger?: string;\n /** Items in the menu. A leading \"-\" draws a separator above it. */\n items?: string[];\n /** Which item the pointer lands on, zero based. */\n chosen?: number;\n /** Frame the menu opens. */\n openAt?: number;\n /** Frame the choice is made. */\n chooseAt?: number;\n};\n\n/**\n * A menu opening and an item being chosen.\n *\n * The menu scales from the corner it is anchored to, which is what says it\n * belongs to the button rather than to the page. The highlight then walks to\n * the chosen row instead of appearing on it, because a pointer that teleports\n * reads as a state change and a pointer that travels reads as a decision.\n */\nexport const DropdownMenu = ({\n trigger = \"Export\",\n items = [\"MP4 video\", \"WebM with alpha\", \"ProRes 4444\", \"-PNG sequence\", \"-Copy frame\"],\n chosen = 1,\n openAt = 22,\n chooseAt = 70,\n}: DropdownMenuProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const opened = spring({frame, fps, delayInFrames: openAt, stiffness: 220, damping: 18});\n // The highlight starts at the top and travels down to the chosen row.\n const walk = interpolate(frame, [openAt + 10, chooseAt], [0, chosen], {easing: Easing.standard});\n const pressed = interpolate(frame, [chooseAt, chooseAt + 8], [0, 1], {easing: Easing.standard});\n const rowHeight = 58;\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#09090B\", justifyContent: \"center\", padding: px(120)}}>\n <div style={{fontFamily: brand.typography.sans, opacity: enter, position: \"relative\"}}>\n <span\n style={{\n background: frame >= openAt ? \"#1C1C20\" : \"transparent\",\n border: `${px(1)}px solid #2A2A30`,\n borderRadius: px(10),\n color: \"#FAFAFA\",\n display: \"inline-block\",\n fontSize: px(24),\n padding: `${px(14)}px ${px(26)}px`,\n }}\n >\n {trigger}\n </span>\n\n <div\n style={{\n background: \"#101014\",\n border: `${px(1)}px solid #26262C`,\n borderRadius: px(12),\n boxShadow: `0 ${px(24)}px ${px(70)}px rgba(0,0,0,0.6)`,\n marginTop: px(10),\n minWidth: px(380),\n opacity: opened,\n overflow: \"hidden\",\n padding: `${px(8)}px`,\n transform: `scale(${0.94 + opened * 0.06})`,\n transformOrigin: \"0% 0%\",\n }}\n >\n {items.map((item, index) => {\n const separated = item.startsWith(\"-\");\n const label = separated ? item.slice(1) : item;\n const near = 1 - Math.min(1, Math.abs(walk - index));\n return (\n <div key={label}>\n {separated ? <div style={{background: \"#26262C\", height: px(1), margin: `${px(6)}px 0`}} /> : null}\n <div\n style={{\n background: `color-mix(in srgb, #26262C ${near * 100}%, transparent)`,\n borderRadius: px(8),\n color: near > 0.5 ? \"#FAFAFA\" : \"#9A9AA2\",\n fontSize: px(22),\n padding: `${px(14)}px ${px(16)}px`,\n transform: `scale(${index === chosen ? 1 - pressed * 0.01 : 1})`,\n }}\n >\n {label}\n </div>\n </div>\n );\n })}\n </div>\n </div>\n </Fill>\n );\n};\n",
1714
+ "target": "videos/components/dropdown-menu/dropdown-menu.tsx"
1715
+ },
1716
+ {
1717
+ "path": "components/dropdown-menu/dropdown-menu.preview.tsx",
1718
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DropdownMenu} from \"./dropdown-menu\";\n\nexport default defineComponentPreview({\n title: \"Dropdown menu\",\n category: \"Interface/Controls\",\n description: \"A menu opening from its trigger, the highlight walking to the item chosen.\",\n component: DropdownMenu,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n trigger: {type: \"text\", defaultValue: \"Export\", maxLength: 24},\n chosen: {type: \"number\", defaultValue: 1, min: 0, max: 6, step: 1},\n chooseAt: {type: \"number\", defaultValue: 70, min: 30, max: 150, step: 5},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Last item\", props: {chosen: 4, chooseAt: 84}},\n ],\n});\n",
1719
+ "target": "videos/components/dropdown-menu/dropdown-menu.preview.tsx"
1720
+ }
1721
+ ],
1722
+ "meta": {
1723
+ "kind": "component",
1724
+ "family": "Interface",
1725
+ "namespaced": "@odori/dropdown-menu",
1726
+ "contract": {
1727
+ "aspectRatios": [
1728
+ "16:9",
1729
+ "1:1"
1730
+ ],
1731
+ "recommendedDurationInFrames": 150,
1732
+ "minimumDurationInFrames": 75,
1733
+ "entranceFrames": 14,
1734
+ "exitFrames": 10,
1735
+ "contentLimits": {
1736
+ "trigger": 24
1737
+ },
1738
+ "reducedMotion": "menu shown open, no walk",
1739
+ "requires": {
1740
+ "fonts": [
1741
+ "sans"
1742
+ ],
1743
+ "audio": []
1744
+ }
1745
+ }
1746
+ }
1747
+ },
1261
1748
  {
1262
1749
  "name": "end-card",
1263
1750
  "description": "The closing frame: title, supporting line, and a call to action.",
@@ -1270,13 +1757,13 @@
1270
1757
  },
1271
1758
  {
1272
1759
  "path": "components/end-card/end-card.preview.tsx",
1273
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {EndCard} from \"./end-card\";\n\nexport default defineComponentPreview({\n title: \"End card\",\n category: \"Brand\",\n description: \"The closing frame: title, supporting line, and a call to action.\",\n component: EndCard,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Author. Preview. Ship.\", maxLength: 48},\n detail: {type: \"text\", defaultValue: \"An independent React video framework.\"},\n callToAction: {type: \"text\", defaultValue: \"npm i odori\"},\n },\n examples: [\n {name: \"Default\", props: {title: \"Author. Preview. Ship.\", callToAction: \"npm i odori\"}},\n {name: \"Title only\", props: {title: \"Available today.\", detail: undefined, callToAction: undefined}},\n ],\n});\n",
1760
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {EndCard} from \"./end-card\";\n\nexport default defineComponentPreview({\n title: \"End card\",\n category: \"Narrative\",\n description: \"The closing frame: title, supporting line, and a call to action.\",\n component: EndCard,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Author. Preview. Ship.\", maxLength: 48},\n detail: {type: \"text\", defaultValue: \"An independent React video framework.\"},\n callToAction: {type: \"text\", defaultValue: \"npm i odori\"},\n },\n examples: [\n {name: \"Default\", props: {title: \"Author. Preview. Ship.\", callToAction: \"npm i odori\"}},\n {name: \"Title only\", props: {title: \"Available today.\", detail: undefined, callToAction: undefined}},\n ],\n});\n",
1274
1761
  "target": "videos/components/end-card/end-card.preview.tsx"
1275
1762
  }
1276
1763
  ],
1277
1764
  "meta": {
1278
1765
  "kind": "component",
1279
- "family": "Brand",
1766
+ "family": "Narrative",
1280
1767
  "namespaced": "@odori/end-card",
1281
1768
  "contract": {
1282
1769
  "aspectRatios": [
@@ -1315,7 +1802,7 @@
1315
1802
  },
1316
1803
  {
1317
1804
  "path": "components/error/error.preview.tsx",
1318
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {error, type ErrorOptions} from \"./error\";\n\nconst Wave = ({root, peak}: ErrorOptions) => <CueWave cue={error({root, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Error\",\n category: \"Sound\",\n description: \"Two notes falling a fourth, for a failure.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n root: {type: \"select\", defaultValue: \"c5\", options: [\"a4\", \"c5\", \"d5\", \"e5\"]},\n peak: {type: \"number\", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Lower\", props: {root: \"a4\"}}],\n});\n",
1805
+ "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {error, type ErrorOptions} from \"./error\";\n\nconst Wave = ({root, peak}: ErrorOptions) => <CueWave cue={error({root, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Error\",\n category: \"Sound/Interface\",\n description: \"Two notes falling a fourth, for a failure.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n root: {type: \"select\", defaultValue: \"c5\", options: [\"a4\", \"c5\", \"d5\", \"e5\"]},\n peak: {type: \"number\", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Lower\", props: {root: \"a4\"}}],\n});\n",
1319
1806
  "target": "videos/components/error/error.preview.tsx"
1320
1807
  }
1321
1808
  ],
@@ -1406,7 +1893,7 @@
1406
1893
  },
1407
1894
  {
1408
1895
  "path": "components/file-tree/file-tree.preview.tsx",
1409
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {FileTree} from \"./file-tree\";\n\nexport default defineComponentPreview({\n title: \"File tree\",\n category: \"Developer proof\",\n description: \"Progressive repository navigation with active file state.\",\n component: FileTree,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"videos/\"},\n activeIndex: {type: \"number\", defaultValue: 4, min: 0, max: 10},\n },\n examples: [\n {\n name: \"Project\",\n props: {\n nodes: [\n {name: \"layout.tsx\", kind: \"file\"},\n {name: \"components/\", kind: \"folder\"},\n {name: \"title-reveal/\", depth: 1, kind: \"folder\"},\n {name: \"launch/\", kind: \"folder\"},\n {name: \"video.tsx\", depth: 1, kind: \"file\"},\n {name: \"schema.ts\", depth: 1, kind: \"file\"},\n ],\n activeIndex: 4,\n },\n },\n ],\n});\n",
1896
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {FileTree} from \"./file-tree\";\n\nexport default defineComponentPreview({\n title: \"File tree\",\n category: \"Developer proof/Code\",\n description: \"Progressive repository navigation with active file state.\",\n component: FileTree,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"videos/\"},\n activeIndex: {type: \"number\", defaultValue: 4, min: 0, max: 10},\n },\n examples: [\n {\n name: \"Project\",\n props: {\n nodes: [\n {name: \"layout.tsx\", kind: \"file\"},\n {name: \"components/\", kind: \"folder\"},\n {name: \"title-reveal/\", depth: 1, kind: \"folder\"},\n {name: \"launch/\", kind: \"folder\"},\n {name: \"video.tsx\", depth: 1, kind: \"file\"},\n {name: \"schema.ts\", depth: 1, kind: \"file\"},\n ],\n activeIndex: 4,\n },\n },\n ],\n});\n",
1410
1897
  "target": "videos/components/file-tree/file-tree.preview.tsx"
1411
1898
  }
1412
1899
  ],
@@ -1449,13 +1936,13 @@
1449
1936
  },
1450
1937
  {
1451
1938
  "path": "components/form-flow/form-flow.preview.tsx",
1452
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {FormFlow} from \"./form-flow\";\n\nexport default defineComponentPreview({\n title: \"Form flow\",\n category: \"Product UI\",\n description: \"Fields, validation, submit, and the success it resolves to.\",\n component: FormFlow,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Create a project\"},\n submit: {type: \"text\", defaultValue: \"Create\"},\n submitAt: {type: \"number\", defaultValue: 100, min: 30, max: 170},\n success: {type: \"text\", defaultValue: \"Project created\"},\n },\n examples: [\n {\n name: \"Default\",\n props: {\n fields: [\n {label: \"Name\", value: \"launch-video\", at: 12},\n {label: \"Source root\", value: \"videos/\", at: 60},\n ],\n },\n },\n {\n name: \"With validation\",\n props: {\n fields: [{label: \"Name\", value: \"Launch Video\", at: 12, error: \"Use a lowercase, dash separated name.\"}],\n submitAt: 90,\n },\n },\n ],\n});\n",
1939
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {FormFlow} from \"./form-flow\";\n\nexport default defineComponentPreview({\n title: \"Form flow\",\n category: \"Interface/Surfaces\",\n description: \"Fields, validation, submit, and the success it resolves to.\",\n component: FormFlow,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Create a project\"},\n submit: {type: \"text\", defaultValue: \"Create\"},\n submitAt: {type: \"number\", defaultValue: 100, min: 30, max: 170},\n success: {type: \"text\", defaultValue: \"Project created\"},\n },\n examples: [\n {\n name: \"Default\",\n props: {\n fields: [\n {label: \"Name\", value: \"launch-video\", at: 12},\n {label: \"Source root\", value: \"videos/\", at: 60},\n ],\n },\n },\n {\n name: \"With validation\",\n props: {\n fields: [{label: \"Name\", value: \"Launch Video\", at: 12, error: \"Use a lowercase, dash separated name.\"}],\n submitAt: 90,\n },\n },\n ],\n});\n",
1453
1940
  "target": "videos/components/form-flow/form-flow.preview.tsx"
1454
1941
  }
1455
1942
  ],
1456
1943
  "meta": {
1457
1944
  "kind": "component",
1458
- "family": "Product UI",
1945
+ "family": "Interface",
1459
1946
  "namespaced": "@odori/form-flow",
1460
1947
  "contract": {
1461
1948
  "aspectRatios": [
@@ -1482,6 +1969,140 @@
1482
1969
  }
1483
1970
  }
1484
1971
  },
1972
+ {
1973
+ "name": "fx",
1974
+ "description": "A monochrome coding agent that prints into the shell's own scrollback instead of taking over the screen.",
1975
+ "registryDependencies": [],
1976
+ "files": [
1977
+ {
1978
+ "path": "components/fx/fx.tsx",
1979
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping, typingFrames} from \"odori\";\n\nexport type FxProps = {\n /** The instruction typed at the prompt. */\n query: string;\n /** The version printed in the welcome line. */\n version?: string;\n /** The permission mode, which the status line brightens. */\n mode?: string;\n /** The model, after the mode on the status line. */\n model?: string;\n /** Lines printed back once the instruction is sent. */\n response?: string[];\n charactersPerSecond?: number;\n};\n\n/**\n * The tool's own palette, read from its source rather than guessed.\n *\n * fx is monochrome by design: what other terminal tools colour, it renders in\n * greys, down to the styles literally named `green_style` and `red_style`\n * being the same 252 as everything else. The only colour anywhere is the plus\n * and minus in a diff, so those are the only two values here that are not a\n * grey. The numbers are xterm-256 indexes, which is how the tool ships them.\n */\nconst HINT = \"#EEEEEE\"; // 255, and bold where the tool uses tag/subtitle\nconst NOTICE = \"#BCBCBC\"; // 250\nconst BRIGHT = \"#D0D0D0\"; // 252, the \"auto\" step above the status line\nconst STATUS = \"#8A8A8A\"; // 245, status line and dim\nconst DIVIDER = \"#585858\"; // 240\n\n/**\n * A coding agent that behaves like a shell command.\n *\n * The restraint is the design, and it goes further than it first looks: there\n * is no box, no banner, no panel, and no colour either. The tool prints a\n * welcome line, takes an instruction after a `❯`, and writes its answer into\n * the same scrollback. Against a bordered welcome card or a status-barred TUI\n * this is the third thing a terminal agent can look like, and the contrast is\n * the reason to keep all three.\n */\nexport const Fx = ({\n query,\n version = \"0.0.3\",\n mode = \"auto\",\n model = \"glm-5.2\",\n response = [],\n charactersPerSecond = 22,\n}: FxProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const from = 22;\n const typed = useTyping(query, {from, charactersPerSecond});\n const sentAt = from + typingFrames(query, {charactersPerSecond}) + 12;\n const enter = interpolate(frame, [0, 12], [0, 1], {easing: Easing.standard});\n const sent = frame >= sentAt;\n\n return (\n <Fill style={{background: \"#0A0A0A\", padding: `${px(90)}px ${px(110)}px`}}>\n <div\n style={{\n fontFamily: brand.typography.mono,\n fontSize: px(26),\n lineHeight: 1.75,\n opacity: enter,\n width: \"100%\",\n }}\n >\n {/* The welcome line, as the tool prints it: the wordmark bold and\n bright, the rest a step down. */}\n <div style={{color: STATUS}}>\n <span style={{color: HINT, fontStyle: \"italic\", fontWeight: 700}}>𝒇x</span>\n {` v${version} · Run /help for commands`}\n </div>\n\n <div style={{color: NOTICE, marginTop: px(26), whiteSpace: \"pre-wrap\"}}>\n <span style={{color: STATUS}}>❯ </span>\n <span style={{color: HINT}}>{typed.text}</span>\n {sent ? null : (\n <span\n style={{\n background: typed.caret ? HINT : \"transparent\",\n display: \"inline-block\",\n height: px(28),\n transform: `translateY(${px(6)}px)`,\n width: px(3),\n }}\n />\n )}\n </div>\n\n {response.length > 0 ? (\n <div style={{marginTop: px(22)}}>\n {response.map((line, index) => {\n // Printed into the same scrollback, one line at a time, the way\n // a command reports rather than the way a panel repaints.\n const at = interpolate(frame, [sentAt + 8 + index * 13, sentAt + 18 + index * 13], [0, 1], {\n easing: Easing.standard,\n });\n return (\n <div key={line} style={{color: NOTICE, display: \"flex\", gap: px(14), opacity: at}}>\n <span style={{color: DIVIDER}}>│</span>\n <span>{line}</span>\n </div>\n );\n })}\n </div>\n ) : null}\n\n {/* The status line: the permission mode one step brighter than the\n rest, which is the only emphasis the tool allows itself. */}\n <div style={{color: STATUS, fontSize: px(23), marginTop: px(30)}}>\n <span style={{color: BRIGHT}}>{mode}</span>\n {` · ${model}`}\n {sent ? \" · working\" : \"\"}\n </div>\n </div>\n </Fill>\n );\n};\n",
1980
+ "target": "videos/components/fx/fx.tsx"
1981
+ },
1982
+ {
1983
+ "path": "components/fx/fx.preview.tsx",
1984
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Fx} from \"./fx\";\n\nexport default defineComponentPreview({\n title: \"fx\",\n category: \"Agents\",\n description: \"A monochrome coding agent that prints into the shell's own scrollback instead of taking over the screen.\",\n component: Fx,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n query: {type: \"text\", defaultValue: \"add a regression test for the chunk cache\", maxLength: 90},\n version: {type: \"text\", defaultValue: \"0.0.3\", maxLength: 12},\n mode: {type: \"select\", defaultValue: \"auto\", options: [\"auto\", \"ask\", \"YOLO\"]},\n model: {type: \"text\", defaultValue: \"glm-5.2\", maxLength: 28},\n charactersPerSecond: {type: \"number\", defaultValue: 22, min: 8, max: 60, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {\n name: \"Working\",\n props: {\n query: \"why did the export change size?\",\n response: [\"read packages/odori-cli/src/render.ts\", \"compared both manifests\", \"the toolchain pin changed the encoder defaults\"],\n },\n },\n ],\n});\n",
1985
+ "target": "videos/components/fx/fx.preview.tsx"
1986
+ }
1987
+ ],
1988
+ "meta": {
1989
+ "kind": "component",
1990
+ "family": "Agents",
1991
+ "namespaced": "@odori/fx",
1992
+ "contract": {
1993
+ "aspectRatios": [
1994
+ "16:9"
1995
+ ],
1996
+ "recommendedDurationInFrames": 270,
1997
+ "minimumDurationInFrames": 120,
1998
+ "entranceFrames": 12,
1999
+ "exitFrames": 12,
2000
+ "contentLimits": {
2001
+ "query": 90,
2002
+ "model": 28
2003
+ },
2004
+ "reducedMotion": "query shown whole, caret still",
2005
+ "requires": {
2006
+ "fonts": [
2007
+ "mono"
2008
+ ],
2009
+ "audio": []
2010
+ }
2011
+ }
2012
+ }
2013
+ },
2014
+ {
2015
+ "name": "github-comment",
2016
+ "description": "An agent answering on a pull request, on the page the answer appears on.",
2017
+ "registryDependencies": [],
2018
+ "files": [
2019
+ {
2020
+ "path": "components/github-comment/github-comment.tsx",
2021
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type GithubCommentProps = {\n /** The repository, as the breadcrumb states it. */\n owner?: string;\n repo?: string;\n /** The pull request. */\n title?: string;\n number?: number;\n /** Branches, for the line under the title. */\n branch?: string;\n base?: string;\n /** The comment that asked for something. */\n request?: {author: string; body: string; time?: string};\n /** Who is answering, and what they say. The body types in. */\n agent?: {name: string; body: string; time?: string};\n /** A suggested change under the reply. */\n suggestion?: {file: string; removed?: string; added?: string};\n /** The check the reply ends on. */\n resolved?: string;\n /** Names in the sidebar's reviewers list. */\n reviewers?: string[];\n labels?: string[];\n thinkingFrames?: number;\n charactersPerSecond?: number;\n};\n\n/** Primer's dark tokens, as GitHub ships them. */\nconst HEADER = \"#151B23\";\nconst CANVAS = \"#212830\";\nconst INSET = \"#262C36\";\nconst EDGE = \"#3D444D\";\nconst INK = \"#D1D7E0\";\nconst MUTED = \"#9198A1\";\nconst LINK = \"#478BE6\";\nconst OPEN = \"#347D39\";\nconst BOT = \"#986EE2\";\nconst FONT = '\"Mona Sans VF\", \"Mona Sans\", -apple-system, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif';\nconst ORANGE = \"#FD8C73\";\n\n/**\n * GitHub's default avatar is an identicon, not a letter.\n *\n * A five by five grid, mirrored down the middle, inset by a margin the way\n * GitHub insets it, with the filled cells and the colour both derived from a\n * hash of the login. GitHub keys it off the account id; the login stands in\n * here, and the only property that matters is the one GitHub relies on: the\n * same name always draws the same mark.\n */\nconst identicon = (name: string, size: number, px: (value: number) => number) => {\n /* FNV-1a, then a mix, because a plain multiply-and-add leaves the low bits\n barely changed between similar logins and every mark comes out alike. */\n let hash = 0x811c9dc5;\n for (const character of name) {\n hash = (hash ^ character.codePointAt(0)!) >>> 0;\n hash = Math.imul(hash, 0x01000193) >>> 0;\n }\n hash = (hash ^ (hash >>> 15)) >>> 0;\n hash = Math.imul(hash, 0x2545f491) >>> 0;\n hash = (hash ^ (hash >>> 13)) >>> 0;\n\n const ink = `hsl(${hash % 360} 58% 50%)`;\n const margin = size / 12;\n const cell = (size - margin * 2) / 5;\n const cells = [];\n for (let column = 0; column < 3; column += 1) {\n for (let row = 0; row < 5; row += 1) {\n /* One bit per cell, taken from a different part of the word each time\n so the three columns do not repeat one another. */\n const bit = (Math.imul(hash, column * 5 + row + 1) >>> ((column * 5 + row) % 13)) & 1;\n if (!bit) continue;\n for (const x of column === 2 ? [2] : [column, 4 - column]) {\n cells.push(\n <rect\n key={`${x}-${row}`}\n x={margin + x * cell}\n y={margin + row * cell}\n width={cell}\n height={cell}\n fill={ink}\n />,\n );\n }\n }\n }\n\n return (\n <span style={{borderRadius: px(999), flex: \"none\", lineHeight: 0, overflow: \"hidden\"}}>\n <svg width={px(size)} height={px(size)} viewBox={`0 0 ${size} ${size}`}>\n <rect width={size} height={size} fill=\"#F0F0F0\" />\n {cells}\n </svg>\n </span>\n );\n};\n\n/**\n * An agent answering on a pull request, on the page the answer appears on.\n *\n * A comment card alone is a screenshot of a comment. The page is what says\n * where the agent is working: the repository above it, the pull request tabs\n * counting what is in review, the timeline the reply joins, and the sidebar\n * that names who is expected to look. The order inside is still the story,\n * though, and it does not change: a person asks, the agent shows it is\n * working, and only then does the answer write itself in.\n */\nexport const GithubComment = ({\n owner = \"johndoe\",\n repo = \"odori\",\n title = \"Pin the render toolchain\",\n number = 128,\n branch = \"fix/toolchain-pin\",\n base = \"main\",\n request = {\n author: \"johndoe\",\n body: \"Why did the export change size between two runs on the same commit?\",\n time: \"9:38 AM\",\n },\n agent = {\n name: \"odori-agent\",\n body: \"The two runs used different FFmpeg builds, so the encoder defaults differed. Pinning both binaries makes the output byte for byte reproducible.\",\n time: \"9:41 AM\",\n },\n suggestion,\n resolved = \"Resolved conversation\",\n reviewers = [\"janedoe\", \"samdoe\"],\n labels = [\"render\", \"bug\"],\n thinkingFrames = 30,\n charactersPerSecond = 34,\n}: GithubCommentProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n /* The typing is the reviewer's: they write the question into the comment\n box and post it, and the agent's answer arrives as a whole comment. */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(request?.body ?? \"\", {charactersPerSecond}) + 10;\n const workingAt = askAt + 18;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(request?.body ?? \"\", {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const answering = spring({frame, fps, delayInFrames: workingAt, stiffness: 150, damping: 16});\n const posted = frame >= askAt;\n const landed = frame >= replyAt;\n const working = frame >= workingAt && frame < replyAt;\n const after = interpolate(landed ? frame : 0, [0, 12], [0, 1], {easing: Easing.standard});\n\n /**\n * A person gets an identicon, the way GitHub draws an account with no\n * picture. An app keeps a solid mark, because that is what a GitHub App\n * shows and it is the fastest way to tell the two apart in a frame.\n */\n const avatar = (name: string, color: string | null, size = 40) =>\n color === null ? (\n identicon(name, size, px)\n ) : (\n <span\n style={{\n alignItems: \"center\",\n background: color,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(size * 0.42),\n fontWeight: 600,\n height: px(size),\n justifyContent: \"center\",\n width: px(size),\n }}\n >\n {name.slice(0, 1).toUpperCase()}\n </span>\n );\n\n /** A comment, drawn the way the timeline draws one: a strip, then a body. */\n const comment = (\n who: {name: string; time?: string; bot?: boolean},\n landed: number,\n body: ReactNode,\n footer?: ReactNode,\n ) => (\n <div\n style={{\n border: `${px(1)}px solid ${who.bot && landed ? BOT : EDGE}`,\n borderRadius: px(10),\n marginTop: px(16),\n opacity: landed,\n overflow: \"hidden\",\n transform: `translateY(${(1 - landed) * px(8)}px)`,\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n background: INSET,\n borderBottom: `${px(1)}px solid ${EDGE}`,\n display: \"flex\",\n gap: px(10),\n padding: `${px(12)}px ${px(16)}px`,\n }}\n >\n {avatar(who.name, who.bot ? BOT : null, 30)}\n <span style={{color: INK, fontSize: px(19), fontWeight: 600}}>{who.name}</span>\n {who.bot ? (\n <span\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(999),\n color: MUTED,\n fontSize: px(14),\n padding: `${px(1)}px ${px(8)}px`,\n }}\n >\n bot\n </span>\n ) : null}\n <span style={{color: MUTED, fontSize: px(17)}}>\n {who.bot && !landed ? \"is working\" : `commented ${who.time ?? \"\"}`}\n </span>\n </div>\n <div style={{padding: `${px(16)}px ${px(18)}px`}}>{body}</div>\n {footer}\n </div>\n );\n\n const dots = (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(7), height: px(26)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: MUTED,\n borderRadius: px(999),\n height: px(9),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(9),\n }}\n />\n ))}\n </span>\n );\n\n const sidebarSection = (heading: string, body: ReactNode) => (\n <div style={{borderBottom: `${px(1)}px solid ${EDGE}`, padding: `${px(18)}px 0`}}>\n <div style={{color: MUTED, fontSize: px(17), fontWeight: 600, marginBottom: px(10)}}>{heading}</div>\n {body}\n </div>\n );\n\n return (\n <Fill style={{background: \"#000000\", padding: px(56)}}>\n <div\n style={{\n background: CANVAS,\n borderRadius: px(12),\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The site header and the repository's own nav. */}\n <div style={{background: HEADER, borderBottom: `${px(1)}px solid ${EDGE}`}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(16), padding: `${px(14)}px ${px(22)}px`}}>\n <span style={{background: INK, borderRadius: px(999), flex: \"none\", height: px(30), width: px(30)}} />\n <span style={{color: INK, fontSize: px(19)}}>\n <span style={{color: LINK}}>{owner}</span>\n <span style={{color: MUTED}}> / </span>\n <span style={{color: LINK, fontWeight: 600}}>{repo}</span>\n </span>\n <span\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(7),\n color: MUTED,\n fontSize: px(16),\n marginLeft: \"auto\",\n padding: `${px(7)}px ${px(60)}px ${px(7)}px ${px(12)}px`,\n }}\n >\n Search\n </span>\n </div>\n <div style={{display: \"flex\", gap: px(22), padding: `0 ${px(22)}px`}}>\n {[\"Code\", \"Issues\", \"Pull requests\", \"Actions\", \"Insights\"].map((tab) => (\n <span\n key={tab}\n style={{\n borderBottom: `${px(2)}px solid ${tab === \"Pull requests\" ? ORANGE : \"transparent\"}`,\n color: tab === \"Pull requests\" ? INK : MUTED,\n fontSize: px(17),\n fontWeight: tab === \"Pull requests\" ? 600 : 400,\n paddingBottom: px(11),\n }}\n >\n {tab}\n </span>\n ))}\n </div>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", minHeight: 0, padding: `${px(22)}px ${px(28)}px 0`}}>\n <div style={{color: INK, fontSize: px(32), fontWeight: 400, letterSpacing: \"-0.01em\"}}>\n {title} <span style={{color: MUTED}}>#{number}</span>\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12), marginTop: px(14)}}>\n <span\n style={{\n alignItems: \"center\",\n background: OPEN,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"inline-flex\",\n fontSize: px(17),\n fontWeight: 600,\n padding: `${px(6)}px ${px(14)}px`,\n }}\n >\n Open\n </span>\n <span style={{color: MUTED, fontSize: px(18)}}>\n <span style={{color: INK}}>{request?.author}</span> wants to merge 3 commits into{\" \"}\n <span style={{background: INSET, borderRadius: px(5), color: INK, fontFamily: brand.typography.mono, padding: `${px(2)}px ${px(7)}px`}}>\n {base}\n </span>{\" \"}\n from{\" \"}\n <span style={{background: INSET, borderRadius: px(5), color: INK, fontFamily: brand.typography.mono, padding: `${px(2)}px ${px(7)}px`}}>\n {branch}\n </span>\n </span>\n </div>\n\n <div style={{borderBottom: `${px(1)}px solid ${EDGE}`, display: \"flex\", gap: px(24), marginTop: px(18)}}>\n {[\n [\"Conversation\", \"2\"],\n [\"Commits\", \"3\"],\n [\"Checks\", \"3\"],\n [\"Files changed\", \"6\"],\n ].map(([tab, count]) => (\n <span\n key={tab}\n style={{\n alignItems: \"center\",\n borderBottom: `${px(2)}px solid ${tab === \"Conversation\" ? ORANGE : \"transparent\"}`,\n color: tab === \"Conversation\" ? INK : MUTED,\n display: \"flex\",\n fontSize: px(18),\n fontWeight: tab === \"Conversation\" ? 600 : 400,\n gap: px(8),\n paddingBottom: px(12),\n }}\n >\n {tab}\n <span style={{background: INSET, borderRadius: px(999), color: MUTED, fontSize: px(15), padding: `${px(1)}px ${px(8)}px`}}>\n {count}\n </span>\n </span>\n ))}\n </div>\n\n <div style={{display: \"flex\", flex: 1, gap: px(28), minHeight: 0, paddingTop: px(6)}}>\n {/* The timeline. */}\n <div style={{flex: 1, minWidth: 0}}>\n {request && posted ? comment({name: request.author, time: request.time}, asked, <span style={{color: INK, fontSize: px(20), lineHeight: 1.55}}>{request.body}</span>) : null}\n\n {frame >= workingAt && agent\n ? comment(\n {name: agent.name, time: agent.time, bot: true},\n answering,\n <div style={{minHeight: px(60)}}>\n {working ? (\n dots\n ) : (\n <span style={{color: INK, fontSize: px(20), lineHeight: 1.55}}>{agent.body}</span>\n )}\n\n {suggestion && landed ? (\n <div\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(8),\n fontFamily: brand.typography.mono,\n fontSize: px(17),\n marginTop: px(14),\n opacity: after,\n overflow: \"hidden\",\n }}\n >\n <div style={{background: INSET, borderBottom: `${px(1)}px solid ${EDGE}`, color: MUTED, padding: `${px(9)}px ${px(14)}px`}}>\n {suggestion.file}\n </div>\n {suggestion.removed ? (\n <div style={{background: \"rgba(248,81,73,0.12)\", color: INK, padding: `${px(7)}px ${px(14)}px`}}>\n <span style={{color: \"#F85149\"}}>- </span>\n {suggestion.removed}\n </div>\n ) : null}\n {suggestion.added ? (\n <div style={{background: \"rgba(63,185,80,0.12)\", color: INK, padding: `${px(7)}px ${px(14)}px`}}>\n <span style={{color: \"#3FB950\"}}>+ </span>\n {suggestion.added}\n </div>\n ) : null}\n </div>\n ) : null}\n </div>,\n resolved && landed ? (\n <div\n style={{\n alignItems: \"center\",\n borderTop: `${px(1)}px solid ${EDGE}`,\n color: MUTED,\n display: \"flex\",\n fontSize: px(18),\n gap: px(10),\n opacity: after,\n padding: `${px(12)}px ${px(18)}px`,\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(20)} height={px(20)}>\n <circle cx=\"12\" cy=\"12\" r=\"10\" fill={BOT} />\n <path d=\"M7.5 12.2l3 3 6-6.4\" fill=\"none\" stroke={CANVAS} strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2.4} />\n </svg>\n {resolved}\n </div>\n ) : undefined,\n )\n : null}\n\n {/* The comment box, where the question is actually written.\n It empties on post, the way the real one does. */}\n <div\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(10),\n marginTop: px(16),\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n background: INSET,\n borderBottom: `${px(1)}px solid ${EDGE}`,\n color: INK,\n fontSize: px(18),\n fontWeight: 600,\n padding: `${px(10)}px ${px(16)}px`,\n }}\n >\n Write\n </div>\n <div style={{color: posted ? MUTED : INK, fontSize: px(20), lineHeight: 1.55, minHeight: px(56), padding: `${px(14)}px ${px(18)}px`}}>\n {posted ? (\n \"Add your comment here...\"\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: INK,\n display: \"inline-block\",\n height: px(19),\n marginLeft: px(2),\n transform: `translateY(${px(3)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </div>\n </div>\n </div>\n\n {/* The sidebar, which names who is expected to look. */}\n <div style={{flex: \"none\", width: px(330)}}>\n {sidebarSection(\n \"Reviewers\",\n <div style={{display: \"grid\", gap: px(10)}}>\n {reviewers.map((name) => (\n <span key={name} style={{alignItems: \"center\", color: INK, display: \"flex\", fontSize: px(18), gap: px(10)}}>\n {avatar(name, null, 26)}\n {name}\n </span>\n ))}\n </div>,\n )}\n {sidebarSection(\n \"Assignees\",\n <span style={{alignItems: \"center\", color: INK, display: \"flex\", fontSize: px(18), gap: px(10)}}>\n {avatar(agent?.name ?? \"odori\", BOT, 26)}\n {agent?.name}\n </span>,\n )}\n {sidebarSection(\n \"Labels\",\n <div style={{display: \"flex\", flexWrap: \"wrap\", gap: px(8)}}>\n {labels.map((label) => (\n <span\n key={label}\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(999),\n color: MUTED,\n fontSize: px(16),\n padding: `${px(4)}px ${px(12)}px`,\n }}\n >\n {label}\n </span>\n ))}\n </div>,\n )}\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
2022
+ "target": "videos/components/github-comment/github-comment.tsx"
2023
+ },
2024
+ {
2025
+ "path": "components/github-comment/github-comment.preview.tsx",
2026
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {GithubComment} from \"./github-comment\";\n\nexport default defineComponentPreview({\n title: \"GitHub comment\",\n category: \"Products\",\n description: \"An agent answering on a pull request, on the page the answer appears on.\",\n component: GithubComment,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Pin the render toolchain\", maxLength: 64},\n owner: {type: \"text\", defaultValue: \"johndoe\", maxLength: 28},\n repo: {type: \"text\", defaultValue: \"odori\", maxLength: 28},\n number: {type: \"number\", defaultValue: 128, min: 1, max: 99999, step: 1},\n resolved: {type: \"text\", defaultValue: \"Resolved conversation\", maxLength: 40},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {\n name: \"With a suggestion\",\n props: {\n suggestion: {\n file: \"packages/odori-cli/src/binaries.ts\",\n removed: 'const chrome = which(\"chrome\");',\n added: 'const chrome = await resolveBrowser(config);',\n },\n },\n },\n ],\n});\n",
2027
+ "target": "videos/components/github-comment/github-comment.preview.tsx"
2028
+ }
2029
+ ],
2030
+ "meta": {
2031
+ "kind": "component",
2032
+ "family": "Products",
2033
+ "namespaced": "@odori/github-comment",
2034
+ "contract": {
2035
+ "aspectRatios": [
2036
+ "16:9"
2037
+ ],
2038
+ "recommendedDurationInFrames": 270,
2039
+ "minimumDurationInFrames": 150,
2040
+ "entranceFrames": 16,
2041
+ "exitFrames": 12,
2042
+ "contentLimits": {
2043
+ "title": 64,
2044
+ "resolved": 40,
2045
+ "owner": 28,
2046
+ "repo": 28,
2047
+ "branch": 40,
2048
+ "base": 24
2049
+ },
2050
+ "reducedMotion": "answer placed whole, no working indicator",
2051
+ "requires": {
2052
+ "fonts": [
2053
+ "sans",
2054
+ "mono"
2055
+ ],
2056
+ "audio": []
2057
+ }
2058
+ }
2059
+ }
2060
+ },
2061
+ {
2062
+ "name": "github-pr",
2063
+ "description": "A pull request whose checks resolve one at a time and unlock the merge.",
2064
+ "registryDependencies": [],
2065
+ "files": [
2066
+ {
2067
+ "path": "components/github-pr/github-pr.tsx",
2068
+ "content": "import {Easing, Fill, interpolate, spring, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\nexport type GithubCheck = {name: string; detail?: string};\n\nexport type GithubPrProps = {\n /** The pull request title. */\n title: string;\n /** The number beside it. */\n number?: number;\n /** Who opened it. */\n author?: string;\n /** The branch it merges from. */\n branch?: string;\n /** The branch it merges into. */\n base?: string;\n /** Files and lines, as the header states them. */\n stats?: {files: number; additions: number; deletions: number};\n /** Checks that resolve one after another, then unlock the merge. */\n checks?: GithubCheck[];\n theme?: \"dark\" | \"light\";\n};\n\nconst PALETTE = {\n dark: {page: \"#212830\", card: \"#262C36\", edge: \"#3D444D\", ink: \"#D1D7E0\", faint: \"#9198A1\", open: \"#347D39\"},\n light: {page: \"#FFFFFF\", card: \"#F6F8FA\", edge: \"#D1D9E0\", ink: \"#1F2328\", faint: \"#59636E\", open: \"#1F883D\"},\n};\n\nconst FONT = '\"Mona Sans VF\", \"Mona Sans\", -apple-system, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif';\n\nconst CHECK_START = 34;\nconst CHECK_STEP = 14;\n\n/**\n * GitHub's default avatar is an identicon, not a letter.\n *\n * A five by five grid, mirrored down the middle, inset by a margin the way\n * GitHub insets it, with the filled cells and the colour both derived from a\n * hash of the login. GitHub keys it off the account id; the login stands in\n * here, and the only property that matters is the one GitHub relies on: the\n * same name always draws the same mark.\n */\nconst identicon = (name: string, size: number, px: (value: number) => number) => {\n /* FNV-1a, then a mix, because a plain multiply-and-add leaves the low bits\n barely changed between similar logins and every mark comes out alike. */\n let hash = 0x811c9dc5;\n for (const character of name) {\n hash = (hash ^ character.codePointAt(0)!) >>> 0;\n hash = Math.imul(hash, 0x01000193) >>> 0;\n }\n hash = (hash ^ (hash >>> 15)) >>> 0;\n hash = Math.imul(hash, 0x2545f491) >>> 0;\n hash = (hash ^ (hash >>> 13)) >>> 0;\n\n const ink = `hsl(${hash % 360} 58% 50%)`;\n const margin = size / 12;\n const cell = (size - margin * 2) / 5;\n const cells = [];\n for (let column = 0; column < 3; column += 1) {\n for (let row = 0; row < 5; row += 1) {\n /* One bit per cell, taken from a different part of the word each time\n so the three columns do not repeat one another. */\n const bit = (Math.imul(hash, column * 5 + row + 1) >>> ((column * 5 + row) % 13)) & 1;\n if (!bit) continue;\n for (const x of column === 2 ? [2] : [column, 4 - column]) {\n cells.push(\n <rect\n key={`${x}-${row}`}\n x={margin + x * cell}\n y={margin + row * cell}\n width={cell}\n height={cell}\n fill={ink}\n />,\n );\n }\n }\n }\n\n return (\n <span style={{borderRadius: px(999), flex: \"none\", lineHeight: 0, overflow: \"hidden\"}}>\n <svg width={px(size)} height={px(size)} viewBox={`0 0 ${size} ${size}`}>\n <rect width={size} height={size} fill=\"#F0F0F0\" />\n {cells}\n </svg>\n </span>\n );\n};\n\n/**\n * A pull request with its checks going green and the merge button unlocking.\n *\n * Checks resolve in sequence rather than together, and the merge button only\n * fills once the last one lands. That order is the whole story: a video that\n * greens everything at once shows a result, and a video that greens them one\n * at a time shows a system working.\n */\nexport const GithubPr = ({\n title,\n number = 128,\n author = \"johndoe\",\n branch = \"feat/agent-surfaces\",\n base = \"main\",\n stats = {files: 6, additions: 214, deletions: 38},\n checks = [\n {name: \"typecheck\", detail: \"12s\"},\n {name: \"test\", detail: \"48s\"},\n {name: \"build\", detail: \"1m 04s\"},\n ],\n theme = \"dark\",\n}: GithubPrProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const settled = CHECK_START + Math.max(0, checks.length - 1) * CHECK_STEP + 12;\n const merge = spring({frame, fps, delayInFrames: settled, stiffness: 160, damping: 16});\n\n return (\n <Fill style={{alignItems: \"center\", background: colors.page, justifyContent: \"center\", padding: px(110)}}>\n <div\n style={{\n fontFamily: FONT,\n maxWidth: px(1280),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(16)}px)`,\n width: \"100%\",\n }}\n >\n <div style={{color: colors.ink, fontSize: px(44), fontWeight: 600, letterSpacing: \"-0.02em\"}}>\n {title} <span style={{color: colors.faint, fontWeight: 400}}>#{number}</span>\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(14), marginTop: px(18)}}>\n <span\n style={{\n alignItems: \"center\",\n background: colors.open,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"inline-flex\",\n fontSize: px(21),\n fontWeight: 600,\n gap: px(8),\n padding: `${px(8)}px ${px(18)}px`,\n }}\n >\n Open\n </span>\n {/* GitHub puts the author's picture in the byline, and for an\n account without one that picture is an identicon. */}\n {identicon(author, 24, px)}\n <span style={{color: colors.faint, fontSize: px(22)}}>\n <span style={{color: colors.ink, fontWeight: 600}}>{author}</span> wants to merge into{\" \"}\n <span style={{color: colors.ink, fontFamily: brand.typography.mono}}>{base}</span> from{\" \"}\n <span style={{color: colors.ink, fontFamily: brand.typography.mono}}>{branch}</span>\n </span>\n </div>\n\n <div\n style={{\n background: colors.card,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(12),\n marginTop: px(30),\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n color: colors.faint,\n display: \"flex\",\n fontFamily: brand.typography.mono,\n fontSize: px(21),\n gap: px(22),\n padding: `${px(18)}px ${px(24)}px`,\n }}\n >\n <span>{stats.files} files</span>\n <span style={{color: \"#3FB950\"}}>+{stats.additions}</span>\n <span style={{color: \"#F85149\"}}>-{stats.deletions}</span>\n </div>\n\n <div style={{display: \"grid\"}}>\n {checks.map((check, index) => {\n // Each check resolves on its own beat, and the tick springs in\n // where the spinner was.\n const done = spring({frame, fps, delayInFrames: CHECK_START + index * CHECK_STEP, stiffness: 200, damping: 15});\n return (\n <div\n key={check.name}\n style={{\n alignItems: \"center\",\n borderTop: index === 0 ? \"none\" : `${px(1)}px solid ${colors.edge}`,\n display: \"flex\",\n gap: px(16),\n padding: `${px(18)}px ${px(24)}px`,\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n display: \"inline-flex\",\n height: px(28),\n justifyContent: \"center\",\n position: \"relative\",\n width: px(28),\n }}\n >\n <span\n style={{\n border: `${px(3)}px solid ${colors.faint}`,\n borderRadius: px(999),\n borderTopColor: \"transparent\",\n height: px(22),\n opacity: 1 - done,\n position: \"absolute\",\n transform: `rotate(${frame * 12}deg)`,\n width: px(22),\n }}\n />\n <svg\n viewBox=\"0 0 24 24\"\n width={px(26)}\n height={px(26)}\n style={{opacity: done, position: \"absolute\", transform: `scale(${0.6 + done * 0.4})`}}\n >\n <circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#3FB950\" />\n <path\n d=\"M7.5 12.2l3 3 6-6.4\"\n fill=\"none\"\n stroke=\"#0D1117\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.4}\n />\n </svg>\n </span>\n <span style={{color: colors.ink, fontSize: px(24), fontWeight: 500}}>{check.name}</span>\n {check.detail ? (\n <span style={{color: colors.faint, fontSize: px(21), marginLeft: \"auto\"}}>{check.detail}</span>\n ) : null}\n </div>\n );\n })}\n </div>\n\n <div\n style={{\n alignItems: \"center\",\n borderTop: `${px(1)}px solid ${colors.edge}`,\n display: \"flex\",\n gap: px(18),\n padding: `${px(22)}px ${px(24)}px`,\n }}\n >\n <span\n style={{\n background: `color-mix(in srgb, ${colors.open} ${merge * 100}%, ${colors.edge})`,\n borderRadius: px(8),\n color: `color-mix(in srgb, #FFFFFF ${40 + merge * 60}%, ${colors.faint})`,\n fontSize: px(24),\n fontWeight: 600,\n padding: `${px(14)}px ${px(28)}px`,\n transform: `scale(${0.98 + merge * 0.02})`,\n }}\n >\n Merge pull request\n </span>\n <span style={{color: colors.faint, fontSize: px(21), opacity: merge}}>All checks have passed</span>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
2069
+ "target": "videos/components/github-pr/github-pr.tsx"
2070
+ },
2071
+ {
2072
+ "path": "components/github-pr/github-pr.preview.tsx",
2073
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {GithubPr} from \"./github-pr\";\n\nexport default defineComponentPreview({\n title: \"GitHub pull request\",\n category: \"Products\",\n description: \"A pull request whose checks resolve one at a time and unlock the merge.\",\n component: GithubPr,\n canvas: {width: 1920, height: 1080, duration: \"7s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Add agent and product surfaces\", maxLength: 72},\n number: {type: \"number\", defaultValue: 128, min: 1, max: 99999, step: 1},\n author: {type: \"text\", defaultValue: \"johndoe\", maxLength: 28},\n branch: {type: \"text\", defaultValue: \"feat/agent-surfaces\", maxLength: 40},\n base: {type: \"text\", defaultValue: \"main\", maxLength: 24},\n theme: {type: \"select\", defaultValue: \"dark\", options: [\"dark\", \"light\"]},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Light\", props: {theme: \"light\"}},\n {\n name: \"One check\",\n props: {title: \"Pin the render toolchain\", checks: [{name: \"e2e\", detail: \"3m 12s\"}]},\n },\n ],\n});\n",
2074
+ "target": "videos/components/github-pr/github-pr.preview.tsx"
2075
+ }
2076
+ ],
2077
+ "meta": {
2078
+ "kind": "component",
2079
+ "family": "Products",
2080
+ "namespaced": "@odori/github-pr",
2081
+ "contract": {
2082
+ "aspectRatios": [
2083
+ "16:9"
2084
+ ],
2085
+ "recommendedDurationInFrames": 210,
2086
+ "minimumDurationInFrames": 120,
2087
+ "entranceFrames": 16,
2088
+ "exitFrames": 12,
2089
+ "contentLimits": {
2090
+ "title": 72,
2091
+ "branch": 40,
2092
+ "base": 24,
2093
+ "author": 28
2094
+ },
2095
+ "reducedMotion": "checks resolved from the first frame, no spinner",
2096
+ "requires": {
2097
+ "fonts": [
2098
+ "sans",
2099
+ "mono"
2100
+ ],
2101
+ "audio": []
2102
+ }
2103
+ }
2104
+ }
2105
+ },
1485
2106
  {
1486
2107
  "name": "gradient-field",
1487
2108
  "description": "Subtle brand-aware background motion that loops seamlessly.",
@@ -1523,6 +2144,51 @@
1523
2144
  }
1524
2145
  }
1525
2146
  },
2147
+ {
2148
+ "name": "halftone-print",
2149
+ "description": "A word screened into halftone dots on a rotated grid, the ink spreading from the middle out.",
2150
+ "registryDependencies": [],
2151
+ "files": [
2152
+ {
2153
+ "path": "components/halftone-print/halftone-print.tsx",
2154
+ "content": "import {Easing, Fill, interpolate, useBrand, useCanvas, useVideo} from \"odori\";\n\nexport type HalftonePrintProps = {\n /** The word rendered as dots. */\n text: string;\n /** The line under it. */\n subtitle?: string;\n /** Distance between dot centres, in composition pixels. */\n pitch?: number;\n /** Screen angle in degrees, the way a print screen is rotated. */\n angle?: number;\n /** Frames the dots take to grow to full size. */\n growFrames?: number;\n /** Ink colour. Defaults to the brand's accent. */\n color?: string;\n};\n\n/**\n * A word screened into halftone dots.\n *\n * The grid is rotated the way a print screen is, because an unrotated screen\n * lines its dots up with the pixel grid and reads as a mistake rather than as\n * print. Dots grow from the centre of the word outwards, so the shape arrives\n * as ink spreading rather than as an image fading up.\n */\nexport const HalftonePrint = ({\n text,\n subtitle,\n pitch = 18,\n angle = 15,\n growFrames = 44,\n color,\n}: HalftonePrintProps) => {\n const {width, height} = useVideo();\n const brand = useBrand();\n const ink = color ?? brand.colors.accent;\n\n const canvas = useCanvas(\n (context, {frame, width: w, height: h}) => {\n context.clearRect(0, 0, w, h);\n\n const sample = 4;\n const columns = Math.floor(w / sample);\n const rows = Math.floor(h / sample);\n const source = document.createElement(\"canvas\");\n source.width = columns;\n source.height = rows;\n const scratch = source.getContext(\"2d\", {willReadFrequently: true});\n if (!scratch) return;\n\n scratch.fillStyle = \"#000000\";\n scratch.fillRect(0, 0, columns, rows);\n scratch.fillStyle = \"#ffffff\";\n scratch.textAlign = \"center\";\n scratch.textBaseline = \"middle\";\n const headline = Math.floor((columns / Math.max(4, text.length)) * 1.6);\n scratch.font = `700 ${headline}px ${brand.typography.sans}`;\n scratch.fillText(text, columns / 2, rows / 2 - (subtitle ? headline * 0.3 : 0), columns * 0.9);\n if (subtitle) {\n const small = Math.max(4, Math.floor(headline * 0.2));\n scratch.font = `500 ${small}px ${brand.typography.sans}`;\n scratch.fillText(subtitle, columns / 2, rows / 2 + headline * 0.55, columns * 0.8);\n }\n const {data} = scratch.getImageData(0, 0, columns, rows);\n\n const grow = interpolate(frame, [8, 8 + growFrames], [0, 1], {easing: Easing.standard});\n const radians = (angle * Math.PI) / 180;\n const cos = Math.cos(radians);\n const sin = Math.sin(radians);\n const reach = Math.hypot(w, h) / 2;\n\n context.fillStyle = ink;\n\n // Walk the rotated grid rather than the pixel grid, which is what makes\n // the screen read as print instead of as a mosaic.\n const span = Math.ceil(Math.hypot(w, h) / pitch) + 2;\n for (let gridY = -span; gridY <= span; gridY += 1) {\n for (let gridX = -span; gridX <= span; gridX += 1) {\n const x = w / 2 + (gridX * cos - gridY * sin) * pitch;\n const y = h / 2 + (gridX * sin + gridY * cos) * pitch;\n if (x < -pitch || y < -pitch || x > w + pitch || y > h + pitch) continue;\n\n const sx = Math.floor((x / w) * columns);\n const sy = Math.floor((y / h) * rows);\n if (sx < 0 || sy < 0 || sx >= columns || sy >= rows) continue;\n const luminance = data[(sy * columns + sx) * 4] / 255;\n if (luminance <= 0.03) continue;\n\n // Ink spreads outwards from the middle of the word.\n const distance = Math.hypot(x - w / 2, y - h / 2) / reach;\n const arrived = interpolate(grow, [distance * 0.7, distance * 0.7 + 0.35], [0, 1], {\n easing: Easing.standard,\n });\n if (arrived <= 0.01) continue;\n\n const radius = (pitch / 2) * Math.sqrt(luminance) * arrived;\n context.beginPath();\n context.arc(x, y, radius, 0, Math.PI * 2);\n context.fill();\n }\n }\n },\n [text, subtitle, pitch, angle, growFrames, ink, brand.typography.sans],\n );\n\n return (\n <Fill style={{alignItems: \"center\", background: brand.colors.background, justifyContent: \"center\"}}>\n <canvas ref={canvas} width={width} height={height} style={{height: \"100%\", width: \"100%\"}} />\n </Fill>\n );\n};\n",
2155
+ "target": "videos/components/halftone-print/halftone-print.tsx"
2156
+ },
2157
+ {
2158
+ "path": "components/halftone-print/halftone-print.preview.tsx",
2159
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {HalftonePrint} from \"./halftone-print\";\n\nexport default defineComponentPreview({\n title: \"Halftone print\",\n category: \"Media/Treatments\",\n description: \"A word screened into halftone dots on a rotated grid, the ink spreading from the middle out.\",\n component: HalftonePrint,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"LAUNCH\", maxLength: 16},\n subtitle: {type: \"text\", defaultValue: \"\", maxLength: 32},\n pitch: {type: \"number\", defaultValue: 18, min: 8, max: 48, step: 1},\n angle: {type: \"number\", defaultValue: 15, min: 0, max: 90, step: 5},\n growFrames: {type: \"number\", defaultValue: 44, min: 10, max: 120, step: 4},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Coarse\", props: {pitch: 30, text: \"0.0.3\"}},\n {name: \"With a line\", props: {subtitle: \"out today\", pitch: 14}},\n ],\n});\n",
2160
+ "target": "videos/components/halftone-print/halftone-print.preview.tsx"
2161
+ }
2162
+ ],
2163
+ "meta": {
2164
+ "kind": "component",
2165
+ "family": "Media",
2166
+ "namespaced": "@odori/halftone-print",
2167
+ "contract": {
2168
+ "aspectRatios": [
2169
+ "16:9",
2170
+ "9:16",
2171
+ "1:1"
2172
+ ],
2173
+ "recommendedDurationInFrames": 180,
2174
+ "minimumDurationInFrames": 75,
2175
+ "entranceFrames": 12,
2176
+ "exitFrames": 10,
2177
+ "contentLimits": {
2178
+ "text": 16,
2179
+ "subtitle": 32
2180
+ },
2181
+ "reducedMotion": "dots shown at full size, no spread",
2182
+ "requires": {
2183
+ "fonts": [
2184
+ "sans",
2185
+ "mono"
2186
+ ],
2187
+ "audio": []
2188
+ }
2189
+ }
2190
+ }
2191
+ },
1526
2192
  {
1527
2193
  "name": "heatmap",
1528
2194
  "description": "Grid intensity reveal for activity and distribution.",
@@ -1579,13 +2245,13 @@
1579
2245
  },
1580
2246
  {
1581
2247
  "path": "components/html-canvas/html-canvas.preview.tsx",
1582
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {HtmlCanvas} from \"./html-canvas\";\n\nconst CARD = `\n <div style=\"display:flex;flex-direction:column;gap:18px;padding:64px;border:1px solid #232323;border-radius:24px;background:#0c0c0c;\">\n <div style=\"font-size:26px;letter-spacing:0.08em;text-transform:uppercase;color:#8f8f8f;\">Deployment</div>\n <div style=\"font-size:76px;font-weight:600;letter-spacing:-0.03em;color:#f5f5f5;\">odori.dev</div>\n <div style=\"font-size:30px;color:#8f8f8f;\">Production · 48s · ready</div>\n </div>\n`;\n\nexport default defineComponentPreview({\n title: \"HTML canvas\",\n category: \"Media and canvas\",\n description: \"HTML authored into a canvas-safe rendered surface.\",\n component: HtmlCanvas,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n entranceFrames: {type: \"number\", defaultValue: 12, min: 0, max: 60},\n },\n examples: [\n {name: \"Default\", props: {html: CARD, width: 1100, height: 460}},\n {\n name: \"Full frame\",\n props: {\n html: `<div style=\"display:flex;align-items:center;justify-content:center;height:100%;font-size:96px;color:#f5f5f5;\">Rasterized</div>`,\n },\n },\n ],\n});\n",
2248
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {HtmlCanvas} from \"./html-canvas\";\n\nconst CARD = `\n <div style=\"display:flex;flex-direction:column;gap:18px;padding:64px;border:1px solid #232323;border-radius:24px;background:#0c0c0c;\">\n <div style=\"font-size:26px;letter-spacing:0.08em;text-transform:uppercase;color:#8f8f8f;\">Deployment</div>\n <div style=\"font-size:76px;font-weight:600;letter-spacing:-0.03em;color:#f5f5f5;\">odori.dev</div>\n <div style=\"font-size:30px;color:#8f8f8f;\">Production · 48s · ready</div>\n </div>\n`;\n\nexport default defineComponentPreview({\n title: \"HTML canvas\",\n category: \"Media/Canvas\",\n description: \"HTML authored into a canvas-safe rendered surface.\",\n component: HtmlCanvas,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n entranceFrames: {type: \"number\", defaultValue: 12, min: 0, max: 60},\n },\n examples: [\n {name: \"Default\", props: {html: CARD, width: 1100, height: 460}},\n {\n name: \"Full frame\",\n props: {\n html: `<div style=\"display:flex;align-items:center;justify-content:center;height:100%;font-size:96px;color:#f5f5f5;\">Rasterized</div>`,\n },\n },\n ],\n});\n",
1583
2249
  "target": "videos/components/html-canvas/html-canvas.preview.tsx"
1584
2250
  }
1585
2251
  ],
1586
2252
  "meta": {
1587
2253
  "kind": "component",
1588
- "family": "Media and canvas",
2254
+ "family": "Media",
1589
2255
  "namespaced": "@odori/html-canvas",
1590
2256
  "contract": {
1591
2257
  "aspectRatios": [
@@ -1600,7 +2266,50 @@
1600
2266
  "contentLimits": {
1601
2267
  "html": 4000
1602
2268
  },
1603
- "reducedMotion": "the raster appears without fading in",
2269
+ "reducedMotion": "the raster appears without fading in",
2270
+ "requires": {
2271
+ "fonts": [
2272
+ "sans"
2273
+ ],
2274
+ "audio": []
2275
+ }
2276
+ }
2277
+ }
2278
+ },
2279
+ {
2280
+ "name": "icon-set",
2281
+ "description": "A stroked icon set that draws itself on, as editable SVG source.",
2282
+ "registryDependencies": [],
2283
+ "files": [
2284
+ {
2285
+ "path": "components/icon-set/icon-set.tsx",
2286
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from \"odori\";\nimport type {CSSProperties} from \"react\";\n\n/**\n * A stroked icon set, drawn on a 24 grid.\n *\n * These are our own geometry rather than someone else's set, for the same\n * reason marks-starter ships placeholder marks: a registry that vendored\n * another project's icons would be making a licensing decision on your\n * behalf. Every glyph is built from the same rules, so they sit together:\n * a 24 box, a 2 stroke, round caps and joins, and no fills.\n *\n * Because they are strokes rather than fills, they can draw themselves on\n * from the frame clock, which is the one thing an icon font cannot do.\n */\nexport const ICONS = {\n check: \"M4 12.5 9.5 18 20 7\",\n close: \"M6 6 18 18M18 6 6 18\",\n plus: \"M12 5v14M5 12h14\",\n minus: \"M5 12h14\",\n \"arrow-right\": \"M4 12h15M13 6l6 6-6 6\",\n \"arrow-up\": \"M12 20V5M6 11l6-6 6 6\",\n \"chevron-right\": \"M9 5l7 7-7 7\",\n search: \"M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14M16.2 16.2 21 21\",\n settings: \"M4 8h10M18 8h2M4 16h4M12 16h8M16 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4M10 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4\",\n user: \"M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8M4 21c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5\",\n users: \"M9 4a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7M2 20c0-3.6 3.1-5.8 7-5.8s7 2.2 7 5.8M16.5 4.5a3.5 3.5 0 0 1 0 7M18 14.6c2.4.7 4 2.6 4 5.4\",\n bell: \"M6 10a6 6 0 0 1 12 0c0 4 1.5 5.5 2 6H4c.5-.5 2-2 2-6M10 20a2 2 0 0 0 4 0\",\n calendar: \"M4 6h16v15H4zM4 10h16M8 3v4M16 3v4\",\n clock: \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 7v5.5l3.5 2\",\n download: \"M12 3v12M7 10.5l5 5 5-5M4 20h16\",\n upload: \"M12 16V4M7 8.5 12 3.5l5 5M4 20h16\",\n folder: \"M3 6h6l2 2.5h10V19H3z\",\n file: \"M6 3h8l4 4v14H6zM14 3v4h4\",\n image: \"M4 5h16v14H4zM4 16l4.5-4.5L13 16M14.5 13.5 17 11l3 3M15.5 8.5h.01\",\n play: \"M8 5.5v13l11-6.5z\",\n pause: \"M9 5v14M15 5v14\",\n heart: \"M12 20S3.5 14.6 3.5 9.2A4.7 4.7 0 0 1 12 6.5a4.7 4.7 0 0 1 8.5 2.7C20.5 14.6 12 20 12 20\",\n star: \"M12 3.5 15 9.6l6.5.9-4.7 4.6 1.1 6.4-5.9-3-5.9 3 1.1-6.4L2.5 10.5l6.5-.9z\",\n bookmark: \"M6 3h12v18l-6-4.5L6 21z\",\n lock: \"M5 11h14v10H5zM8 11V7.5a4 4 0 0 1 8 0V11\",\n mail: \"M3 5h18v14H3zM3 6l9 7 9-7\",\n message: \"M3 5h18v12h-9l-5 4v-4H3z\",\n link: \"M10 14a4 4 0 0 0 5.7 0l3-3A4 4 0 0 0 13 5.4l-1.5 1.5M14 10a4 4 0 0 0-5.7 0l-3 3A4 4 0 0 0 11 18.6l1.5-1.5\",\n external: \"M14 4h6v6M20 4l-9 9M18 14v6H4V6h6\",\n trash: \"M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6M14 11v6\",\n edit: \"M4 20h4L20 8l-4-4L4 16zM15 5l4 4\",\n copy: \"M9 3h12v12H9zM15 15v6H3V9h6\",\n refresh: \"M20 12a8 8 0 1 1-2.6-5.9M20 4v5h-5\",\n filter: \"M3 5h18l-7 8v6l-4 2v-8z\",\n grid: \"M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z\",\n list: \"M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01\",\n home: \"M4 11 12 4l8 7v9h-5v-6H9v6H4z\",\n globe: \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3\",\n code: \"M9 7 4 12l5 5M15 7l5 5-5 5\",\n terminal: \"M4 5h16v14H4zM7.5 9.5 10 12l-2.5 2.5M12.5 15h4\",\n database: \"M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3\",\n cloud: \"M7 19a4 4 0 0 1-.4-8A6 6 0 0 1 18 10.4 4.3 4.3 0 0 1 17.5 19z\",\n zap: \"M13 3 5 14h6l-1 7 8-11h-6z\",\n shield: \"M12 3l8 3v6c0 5-3.4 8.3-8 9.5C7.4 20.3 4 17 4 12V6z\",\n eye: \"M2.5 12S6 6 12 6s9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6M12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5\",\n sun: \"M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8\",\n moon: \"M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5\",\n \"git-branch\": \"M6 4v11M6 20a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5M18 9a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5M18 9v1.5a4 4 0 0 1-4 4H9\",\n package: \"M12 3 21 7.5v9L12 21l-9-4.5v-9zM3 7.5l9 4.5 9-4.5M12 12v9\",\n sparkles: \"M12 3.5 13.6 8 18 9.6 13.6 11.2 12 15.7 10.4 11.2 6 9.6 10.4 8zM18.5 15l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z\",\n alert: \"M12 4 22 20H2zM12 10v4.5M12 17.5h.01\",\n info: \"M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 11v6M12 7.5h.01\",\n} as const;\n\nexport type IconName = keyof typeof ICONS;\n\n/** The order the gallery walks, which is also a sensible default set. */\nexport const ICON_NAMES = Object.keys(ICONS) as IconName[];\n\nexport type IconProps = {\n name: IconName;\n /** Defaults to the brand's foreground, so an icon inherits the palette. */\n color?: string;\n /** Size in design pixels, scaled for the format. */\n size?: number;\n /** Stroke weight on the 24 grid, before scaling. */\n weight?: number;\n /** Draw the icon on across this many frames. Zero renders it complete. */\n drawFrames?: number;\n /** Frames to wait before drawing, for staggering a set of them. */\n delay?: number;\n style?: CSSProperties;\n};\n\n/**\n * One icon, optionally drawing itself on.\n *\n * The draw is a dash offset rather than a clip, so it follows the path the\n * way a pen would instead of wiping across the box.\n */\nexport const Icon = ({name, color, size = 24, weight = 2, drawFrames = 0, delay = 0, style}: IconProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const drawn =\n drawFrames <= 0 ? 1 : interpolate(frame, [delay, delay + drawFrames], [0, 1], {easing: Easing.standard});\n\n return (\n <svg\n aria-hidden=\"true\"\n fill=\"none\"\n height={size * scale}\n stroke={color ?? brand.colors.foreground}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={weight}\n style={style}\n viewBox=\"0 0 24 24\"\n width={size * scale}\n >\n <path\n d={ICONS[name]}\n /* 120 comfortably exceeds the longest path here, so one number\n serves every glyph and none of them ever draw short. */\n strokeDasharray={drawFrames > 0 ? 120 : undefined}\n strokeDashoffset={drawFrames > 0 ? 120 * (1 - drawn) : undefined}\n />\n </svg>\n );\n};\n\nexport type IconSetProps = {\n /** Which icons to show, in order. Defaults to the whole set. */\n names?: IconName[];\n /** Columns in the grid. */\n columns?: number;\n /** Frames between one icon starting and the next. */\n stagger?: number;\n /** Frames each icon takes to draw. */\n drawFrames?: number;\n /** Label under each icon. Off by default, because a wall of names is noise. */\n labels?: boolean;\n color?: string;\n size?: number;\n};\n\n/**\n * The set, drawing itself on.\n *\n * A gallery is the honest way to show an icon set: the whole thing at once,\n * at the size it will actually be used, rather than three of them blown up.\n * The stagger runs in reading order so the eye has somewhere to go.\n */\nexport const IconSet = ({\n names = ICON_NAMES,\n columns = 10,\n stagger = 2,\n drawFrames = 18,\n labels = false,\n color,\n size = 44,\n}: IconSetProps) => {\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", padding: px(80)}}>\n <div\n style={{\n display: \"grid\",\n gap: px(labels ? 26 : 34),\n gridTemplateColumns: `repeat(${columns}, 1fr)`,\n width: \"100%\",\n }}\n >\n {names.map((name, index) => (\n <div key={name} style={{alignItems: \"center\", display: \"grid\", gap: px(10), justifyItems: \"center\"}}>\n <Icon color={color} delay={index * stagger} drawFrames={drawFrames} name={name} size={size} />\n {labels ? (\n <span\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.sans,\n fontSize: px(15),\n textAlign: \"center\",\n }}\n >\n {name}\n </span>\n ) : null}\n </div>\n ))}\n </div>\n </Fill>\n );\n};\n",
2287
+ "target": "videos/components/icon-set/icon-set.tsx"
2288
+ },
2289
+ {
2290
+ "path": "components/icon-set/icon-set.preview.tsx",
2291
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {IconSet} from \"./icon-set\";\n\nexport default defineComponentPreview({\n title: \"Icon set\",\n category: \"Brand\",\n description: \"A stroked icon set that draws itself on, as editable SVG source.\",\n component: IconSet,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n columns: {type: \"number\", defaultValue: 10, min: 3, max: 14, step: 1},\n size: {type: \"number\", defaultValue: 44, min: 16, max: 120, step: 2},\n stagger: {type: \"number\", defaultValue: 2, min: 0, max: 12, step: 1},\n drawFrames: {type: \"number\", defaultValue: 18, min: 0, max: 60, step: 1},\n labels: {type: \"boolean\", defaultValue: false},\n },\n examples: [\n {name: \"The set\", props: {}},\n {name: \"Named\", props: {columns: 8, labels: true, size: 40}},\n {\n name: \"A few, large\",\n props: {\n columns: 5,\n drawFrames: 26,\n names: [\"sparkles\", \"zap\", \"shield\", \"git-branch\", \"terminal\"],\n size: 96,\n stagger: 8,\n },\n },\n ],\n});\n",
2292
+ "target": "videos/components/icon-set/icon-set.preview.tsx"
2293
+ }
2294
+ ],
2295
+ "meta": {
2296
+ "kind": "component",
2297
+ "family": "Brand",
2298
+ "namespaced": "@odori/icon-set",
2299
+ "contract": {
2300
+ "aspectRatios": [
2301
+ "16:9",
2302
+ "9:16",
2303
+ "1:1"
2304
+ ],
2305
+ "recommendedDurationInFrames": 180,
2306
+ "minimumDurationInFrames": 60,
2307
+ "entranceFrames": 18,
2308
+ "exitFrames": 0,
2309
+ "contentLimits": {
2310
+ "names": 56
2311
+ },
2312
+ "reducedMotion": "every icon renders complete, with no draw on",
1604
2313
  "requires": {
1605
2314
  "fonts": [
1606
2315
  "sans"
@@ -1622,13 +2331,13 @@
1622
2331
  },
1623
2332
  {
1624
2333
  "path": "components/identity-reveal/identity-reveal.preview.tsx",
1625
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {IdentityReveal} from \"./identity-reveal\";\n\nexport default defineComponentPreview({\n title: \"Identity reveal\",\n category: \"Foundation\",\n description: \"Product mark and name entrance with visual restraint.\",\n component: IdentityReveal,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n wordmark: {type: \"text\", defaultValue: \"odori\", maxLength: 24},\n detail: {type: \"text\", defaultValue: \"An independent React video framework.\"},\n },\n examples: [\n {name: \"Mark and name\", props: {}},\n {name: \"Name only\", props: {wordmark: \"odori\", detail: \"\"}},\n ],\n});\n",
2334
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {IdentityReveal} from \"./identity-reveal\";\n\nexport default defineComponentPreview({\n title: \"Identity reveal\",\n category: \"Brand\",\n description: \"Product mark and name entrance with visual restraint.\",\n component: IdentityReveal,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n wordmark: {type: \"text\", defaultValue: \"odori\", maxLength: 24},\n detail: {type: \"text\", defaultValue: \"An independent React video framework.\"},\n },\n examples: [\n {name: \"Mark and name\", props: {}},\n {name: \"Name only\", props: {wordmark: \"odori\", detail: \"\"}},\n ],\n});\n",
1626
2335
  "target": "videos/components/identity-reveal/identity-reveal.preview.tsx"
1627
2336
  }
1628
2337
  ],
1629
2338
  "meta": {
1630
2339
  "kind": "component",
1631
- "family": "Foundation",
2340
+ "family": "Brand",
1632
2341
  "namespaced": "@odori/identity-reveal",
1633
2342
  "contract": {
1634
2343
  "aspectRatios": [
@@ -1666,13 +2375,13 @@
1666
2375
  },
1667
2376
  {
1668
2377
  "path": "components/image-stage/image-stage.preview.tsx",
1669
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ImageStage} from \"./image-stage\";\n\nexport default defineComponentPreview({\n title: \"Image stage\",\n category: \"Media and canvas\",\n description: \"A still with a slow push, held until it has decoded.\",\n component: ImageStage,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n fit: {type: \"select\", defaultValue: \"cover\", options: [\"cover\", \"contain\"]},\n zoom: {type: \"number\", defaultValue: 0.06, min: -0.2, max: 0.4, step: 0.02},\n entranceFrames: {type: \"number\", defaultValue: 16, min: 0, max: 60},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Pan right\", props: {pan: {x: -0.04}, zoom: 0.1}},\n {name: \"Contain\", props: {fit: \"contain\", zoom: 0}},\n ],\n});\n",
2378
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ImageStage} from \"./image-stage\";\n\nexport default defineComponentPreview({\n title: \"Image stage\",\n category: \"Media/Footage\",\n description: \"A still with a slow push, held until it has decoded.\",\n component: ImageStage,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n fit: {type: \"select\", defaultValue: \"cover\", options: [\"cover\", \"contain\"]},\n zoom: {type: \"number\", defaultValue: 0.06, min: -0.2, max: 0.4, step: 0.02},\n entranceFrames: {type: \"number\", defaultValue: 16, min: 0, max: 60},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Pan right\", props: {pan: {x: -0.04}, zoom: 0.1}},\n {name: \"Contain\", props: {fit: \"contain\", zoom: 0}},\n ],\n});\n",
1670
2379
  "target": "videos/components/image-stage/image-stage.preview.tsx"
1671
2380
  }
1672
2381
  ],
1673
2382
  "meta": {
1674
2383
  "kind": "component",
1675
- "family": "Media and canvas",
2384
+ "family": "Media",
1676
2385
  "namespaced": "@odori/image-stage",
1677
2386
  "contract": {
1678
2387
  "aspectRatios": [
@@ -1695,6 +2404,48 @@
1695
2404
  }
1696
2405
  }
1697
2406
  },
2407
+ {
2408
+ "name": "imessage",
2409
+ "description": "Messages with the conversation list beside the thread, the reply growing from the typing bubble.",
2410
+ "registryDependencies": [],
2411
+ "files": [
2412
+ {
2413
+ "path": "components/imessage/imessage.tsx",
2414
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type IMessageBubble = {\n /** \"you\" is the blue bubble on the right, \"them\" the grey one on the left. */\n from: \"them\" | \"you\";\n body: string;\n};\n\nexport type IMessageConversation = {\n name: string;\n preview: string;\n time?: string;\n};\n\nexport type IMessageProps = {\n /** The conversation list down the left. The first one is the open thread. */\n conversations?: IMessageConversation[];\n /** Who the open thread is with. */\n contact?: string;\n /** Bubbles already in the thread. */\n bubbles?: IMessageBubble[];\n /** The reply, after a typing bubble. */\n reply?: IMessageBubble;\n /** Frames the typing bubble holds. */\n thinkingFrames?: number;\n /** The receipt under the last bubble. */\n receipt?: string;\n /** The stamp Messages centres in the thread when a conversation resumes. */\n stamp?: string;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\n/**\n * Apple's system palette rather than an approximation of it: the greys are\n * systemGray4 through systemGray6, and the blue is systemBlue, which shifts\n * a step brighter in the dark so it holds the same weight against black.\n */\nconst PALETTE = {\n dark: {\n page: \"#000000\",\n list: \"#1C1C1E\",\n edge: \"#38383A\",\n them: \"#3B3B3D\",\n ink: \"#FFFFFF\",\n faint: \"#8E8E93\",\n field: \"#1C1C1E\",\n blue: \"#0A84FF\",\n },\n light: {\n page: \"#FFFFFF\",\n list: \"#F6F6F6\",\n edge: \"#D1D1D6\",\n them: \"#E9E9EB\",\n ink: \"#000000\",\n faint: \"#8E8E93\",\n field: \"#FFFFFF\",\n blue: \"#007AFF\",\n },\n};\n/** Messages sets its interface in the system face, SF Pro on Apple hardware. */\nconst FONT = '-apple-system, \"SF Pro Text\", \"SF Pro\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\n\n/**\n * Messages draws a contact with no photo as initials on a grey, never on a\n * tinted circle. Two initials where there are two names, one otherwise.\n */\nconst CONTACT_GREY = \"linear-gradient(#A5A5AA, #8A8A8F)\";\n\nconst initials = (name: string) => {\n const words = name.trim().split(/\\s+/).filter(Boolean);\n if (words.length === 0) return \"\";\n if (words.length === 1) return words[0].slice(0, 1).toUpperCase();\n return (words[0][0] + words[words.length - 1][0]).toUpperCase();\n};\n\n/**\n * Messages, with the conversation list beside the thread.\n *\n * The list is what makes this a phone rather than a chat widget: it says this\n * is one thread among many, that the agent sits in the same place as the\n * people you text, and it gives the frame somewhere for the eye to rest while\n * the thread does the moving. The typing bubble is still the point, and the\n * reply grows out of exactly where the dots were.\n */\nexport const IMessage = ({\n conversations = [\n {name: \"Odori\", preview: \"Done. out/launch-30s.mp4, same brand.\", time: \"now\"},\n {name: \"John Doe\", preview: \"sounds good, ship it\", time: \"9:12 AM\"},\n {name: \"Design\", preview: \"Dan: new mark is in Figma\", time: \"Yesterday\"},\n ],\n contact = \"Odori\",\n bubbles = [{from: \"you\", body: \"can you cut a 30 second version of the launch video?\"}],\n reply = {from: \"them\", body: \"Done. out/launch-30s.mp4, same brand, ends on the mark.\"},\n thinkingFrames = 34,\n receipt = \"Delivered\",\n stamp = \"Today 11:31 AM\",\n theme = \"dark\",\n charactersPerSecond = 32,\n}: IMessageProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n\n const scale = useDesignScale();\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n /* The typing belongs to the person holding the phone. They compose the\n last bubble in the field and send it; the reply arrives whole, the way\n a message from someone else does. */\n const last = bubbles[bubbles.length - 1];\n const earlier = bubbles.slice(0, -1);\n const first = 18;\n const step = 18;\n const typeFrom = first + earlier.length * step;\n const typed = useTyping(last?.body ?? \"\", {from: typeFrom, charactersPerSecond, chunk: 2});\n const sendAt = typeFrom + typingFrames(last?.body ?? \"\", {charactersPerSecond}) + 10;\n const typingAt = sendAt + 12;\n const replyAt = typingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const landed = spring({frame, fps, delayInFrames: replyAt, stiffness: 180, damping: 15});\n const sent = frame >= sendAt;\n const typing = frame >= typingAt && frame < replyAt;\n\n /** The small circle Messages puts beside a bubble that is not yours. */\n const contactAvatar = (name: string, size: number) => (\n <span\n style={{\n alignItems: \"center\",\n background: CONTACT_GREY,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(size * 0.38),\n height: px(size),\n justifyContent: \"center\",\n marginBottom: px(2),\n width: px(size),\n }}\n >\n {initials(name)}\n </span>\n );\n\n /**\n * A bubble, with the tail Messages draws on it.\n *\n * The tail is two shapes rather than a corner radius: one in the bubble's\n * own colour reaching past its edge, and one in the page colour carving\n * that back into a hook. A small radius on the corner reads as a chamfer\n * and never as a tail, which is what the earlier version did.\n */\n const bubble = (\n from: \"them\" | \"you\",\n grown: number,\n body: ReactNode,\n key?: string,\n tight = false,\n ) => {\n const mine = from === \"you\";\n const skin = mine ? colors.blue : colors.them;\n const side = mine ? \"right\" : \"left\";\n\n return (\n <div\n key={key}\n style={{\n alignItems: \"flex-end\",\n display: \"flex\",\n gap: px(20),\n justifyContent: mine ? \"flex-end\" : \"flex-start\",\n opacity: grown,\n padding: `${px(3)}px ${px(26)}px`,\n }}\n >\n {mine ? null : contactAvatar(contact, 34)}\n <div\n style={{\n background: skin,\n borderBottomLeftRadius: mine ? px(26) : px(11),\n borderBottomRightRadius: mine ? px(11) : px(26),\n borderRadius: px(26),\n color: mine ? \"#FFFFFF\" : colors.ink,\n fontSize: px(23),\n lineHeight: 1.29,\n maxWidth: \"68%\",\n padding: tight ? `${px(13)}px ${px(18)}px` : `${px(14)}px ${px(20)}px`,\n position: \"relative\",\n transform: `scale(${0.8 + grown * 0.2})`,\n transformOrigin: mine ? \"100% 100%\" : \"0% 100%\",\n }}\n >\n {body}\n {/* The tail, as one filled path rather than a coloured rectangle\n carved by a second one in the page colour. The carve version\n paints over whatever sits beside the bubble, which here is the\n contact's picture. */}\n <svg\n viewBox=\"0 0 16 22\"\n width={px(21)}\n height={px(29)}\n style={{bottom: 0, position: \"absolute\", [side]: px(-16)}}\n >\n <path\n d={mine ? \"M16 22C7 22 0 15.6 0 6.5L0 22Z\" : \"M0 22C9 22 16 15.6 16 6.5L16 22Z\"}\n fill={skin}\n />\n </svg>\n </div>\n </div>\n );\n };\n\n return (\n <Fill style={{background: \"#000000\", padding: px(60)}}>\n <div\n style={{\n borderRadius: px(16),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The conversation list. */}\n <div style={{background: colors.list, borderRight: `${px(1)}px solid ${colors.edge}`, flex: \"none\", width: px(400)}}>\n <div style={{color: colors.ink, fontSize: px(30), fontWeight: 700, letterSpacing: \"-0.02em\", padding: `${px(24)}px ${px(22)}px ${px(14)}px`}}>\n Messages\n </div>\n <div\n style={{\n background: theme === \"dark\" ? \"#2C2C2E\" : \"#E9E9EB\",\n borderRadius: px(10),\n color: colors.faint,\n fontSize: px(19),\n margin: `0 ${px(18)}px ${px(14)}px`,\n padding: `${px(10)}px ${px(14)}px`,\n }}\n >\n Search\n </div>\n {conversations.map((item, index) => (\n <div\n key={item.name}\n style={{\n background: index === 0 ? (theme === \"dark\" ? \"#2C2C2E\" : \"#DCDCE0\") : \"transparent\",\n borderRadius: px(10),\n display: \"flex\",\n gap: px(14),\n margin: `${px(2)}px ${px(12)}px`,\n padding: `${px(14)}px ${px(12)}px`,\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: CONTACT_GREY,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(20),\n height: px(50),\n justifyContent: \"center\",\n width: px(50),\n }}\n >\n {initials(item.name)}\n </span>\n <div style={{minWidth: 0, width: \"100%\"}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(10)}}>\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{item.name}</span>\n <span style={{color: colors.faint, fontSize: px(16), marginLeft: \"auto\"}}>{item.time}</span>\n </div>\n <div\n style={{\n color: colors.faint,\n fontSize: px(18),\n marginTop: px(3),\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.preview}\n </div>\n </div>\n </div>\n ))}\n </div>\n\n {/* The thread. */}\n <div style={{background: colors.page, display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div\n style={{\n alignItems: \"center\",\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n display: \"flex\",\n flexDirection: \"column\",\n gap: px(6),\n padding: `${px(16)}px ${px(24)}px`,\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: CONTACT_GREY,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(19),\n height: px(48),\n justifyContent: \"center\",\n width: px(48),\n }}\n >\n {initials(contact)}\n </span>\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600, letterSpacing: \"-0.01em\"}}>{contact}</span>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", justifyContent: \"flex-end\", paddingBottom: px(10)}}>\n {/* Messages centres a stamp in the thread rather than putting a\n time on each bubble, and the day is the part it emphasises. */}\n {stamp ? (\n <div\n style={{\n color: colors.faint,\n fontSize: px(17),\n padding: `${px(10)}px 0 ${px(14)}px`,\n textAlign: \"center\",\n }}\n >\n <span style={{color: colors.ink, fontWeight: 600}}>{stamp.split(\" \")[0]}</span>{\" \"}\n {stamp.split(\" \").slice(1).join(\" \")}\n </div>\n ) : null}\n {earlier.map((item, index) =>\n bubble(\n item.from,\n spring({frame, fps, delayInFrames: first + index * step, stiffness: 180, damping: 15}),\n item.body,\n `${item.from}-${index}`,\n ),\n )}\n {last && sent\n ? bubble(\n last.from,\n spring({frame, fps, delayInFrames: sendAt, stiffness: 180, damping: 15}),\n last.body,\n \"sent\",\n )\n : null}\n\n {typing\n ? bubble(\n reply.from,\n spring({frame, fps, delayInFrames: typingAt, stiffness: 200, damping: 14}),\n <span style={{alignItems: \"center\", display: \"flex\", gap: px(9), height: px(15)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: colors.faint,\n borderRadius: px(999),\n height: px(15),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(15),\n }}\n />\n ))}\n </span>,\n \"typing\",\n true,\n )\n : null}\n\n {frame >= replyAt\n ? bubble(\n reply.from,\n landed,\n reply.body,\n )\n : null}\n\n {receipt && frame >= replyAt ? (\n <div style={{color: colors.faint, fontSize: px(16), padding: `${px(4)}px ${px(30)}px 0`, textAlign: \"right\"}}>\n {receipt}\n </div>\n ) : null}\n </div>\n\n {/* The composer, with what Messages puts around the field: the\n apps button on the left, and a send arrow that only appears\n once there is something to send. */}\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12), margin: `0 ${px(24)}px ${px(20)}px`}}>\n <span\n style={{\n alignItems: \"center\",\n background: colors.field,\n borderRadius: px(999),\n color: colors.faint,\n display: \"flex\",\n flex: \"none\",\n fontSize: px(26),\n height: px(38),\n justifyContent: \"center\",\n width: px(38),\n }}\n >\n +\n </span>\n <div\n style={{\n alignItems: \"center\",\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n color: sent ? colors.faint : colors.ink,\n display: \"flex\",\n flex: 1,\n fontSize: px(20),\n gap: px(10),\n minWidth: 0,\n padding: `${px(9)}px ${px(9)}px ${px(9)}px ${px(20)}px`,\n }}\n >\n <span style={{flex: 1, minWidth: 0}}>\n {sent ? (\n \"iMessage\"\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(22),\n marginLeft: px(2),\n transform: `translateY(${px(4)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </span>\n {sent ? (\n /* An empty field carries the audio and emoji controls; the\n send arrow replaces them only once there is something to\n send, which is the swap Messages does. */\n <span style={{alignItems: \"center\", color: colors.faint, display: \"flex\", flex: \"none\", gap: px(12)}}>\n <svg viewBox=\"0 0 24 24\" width={px(24)} height={px(24)} stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\">\n <path d=\"M4 11v2M8 8v8M12 5v14M16 8v8M20 11v2\" />\n </svg>\n <svg viewBox=\"0 0 24 24\" width={px(26)} height={px(26)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.7}>\n <circle cx=\"12\" cy=\"12\" r=\"9.2\" />\n <circle cx=\"9\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <circle cx=\"15\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <path d=\"M8.4 14.2a4.6 4.6 0 0 0 7.2 0\" strokeLinecap=\"round\" />\n </svg>\n </span>\n ) : (\n <span\n style={{\n alignItems: \"center\",\n background: colors.blue,\n borderRadius: px(999),\n display: \"flex\",\n flex: \"none\",\n height: px(30),\n justifyContent: \"center\",\n width: px(30),\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(18)} height={px(18)} fill=\"none\" stroke=\"#FFFFFF\" strokeWidth={2.6}>\n <path d=\"M12 19V6M6 12l6-6 6 6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </span>\n )}\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
2415
+ "target": "videos/components/imessage/imessage.tsx"
2416
+ },
2417
+ {
2418
+ "path": "components/imessage/imessage.preview.tsx",
2419
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {IMessage} from \"./imessage\";\n\nexport default defineComponentPreview({\n title: \"iMessage\",\n category: \"Products\",\n description: \"Messages with the conversation list beside the thread, the reply growing from the typing bubble.\",\n component: IMessage,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n contact: {type: \"text\", defaultValue: \"Odori\", maxLength: 24},\n receipt: {type: \"text\", defaultValue: \"Delivered\", maxLength: 20},\n thinkingFrames: {type: \"number\", defaultValue: 34, min: 0, max: 90, step: 2},\n theme: {type: \"select\", defaultValue: \"dark\", options: [\"dark\", \"light\"]},\n charactersPerSecond: {type: \"number\", defaultValue: 32, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Light\", props: {theme: \"light\"}},\n ],\n});\n",
2420
+ "target": "videos/components/imessage/imessage.preview.tsx"
2421
+ }
2422
+ ],
2423
+ "meta": {
2424
+ "kind": "component",
2425
+ "family": "Products",
2426
+ "namespaced": "@odori/imessage",
2427
+ "contract": {
2428
+ "aspectRatios": [
2429
+ "16:9"
2430
+ ],
2431
+ "recommendedDurationInFrames": 270,
2432
+ "minimumDurationInFrames": 150,
2433
+ "entranceFrames": 14,
2434
+ "exitFrames": 12,
2435
+ "contentLimits": {
2436
+ "contact": 24,
2437
+ "receipt": 20
2438
+ },
2439
+ "reducedMotion": "bubbles placed, no typing bubble",
2440
+ "requires": {
2441
+ "fonts": [
2442
+ "sans"
2443
+ ],
2444
+ "audio": []
2445
+ }
2446
+ }
2447
+ }
2448
+ },
1698
2449
  {
1699
2450
  "name": "kinetic-center",
1700
2451
  "description": "A centered statement assembled from timed word groups.",
@@ -1707,7 +2458,7 @@
1707
2458
  },
1708
2459
  {
1709
2460
  "path": "components/kinetic-center/kinetic-center.preview.tsx",
1710
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {KineticCenter} from \"./kinetic-center\";\n\nexport default defineComponentPreview({\n title: \"Kinetic center\",\n category: \"Typography\",\n description: \"A centered statement assembled from timed word groups.\",\n component: KineticCenter,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n accentIndex: {type: \"number\", defaultValue: 2, min: 0, max: 5},\n },\n examples: [\n {name: \"Default\", props: {groups: [\"Make\", \"motion\", \"useful.\"], accentIndex: 2}},\n {name: \"Longer\", props: {groups: [\"Every\", \"frame\", \"is\", \"a\", \"decision.\"], accentIndex: 4}},\n ],\n});\n",
2461
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {KineticCenter} from \"./kinetic-center\";\n\nexport default defineComponentPreview({\n title: \"Kinetic center\",\n category: \"Typography/Titles\",\n description: \"A centered statement assembled from timed word groups.\",\n component: KineticCenter,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n accentIndex: {type: \"number\", defaultValue: 2, min: 0, max: 5},\n },\n examples: [\n {name: \"Default\", props: {groups: [\"Make\", \"motion\", \"useful.\"], accentIndex: 2}},\n {name: \"Longer\", props: {groups: [\"Every\", \"frame\", \"is\", \"a\", \"decision.\"], accentIndex: 4}},\n ],\n});\n",
1711
2462
  "target": "videos/components/kinetic-center/kinetic-center.preview.tsx"
1712
2463
  }
1713
2464
  ],
@@ -1781,6 +2532,53 @@
1781
2532
  }
1782
2533
  }
1783
2534
  },
2535
+ {
2536
+ "name": "linear",
2537
+ "description": "A Linear workspace where an agent comments on an issue and moves it to done.",
2538
+ "registryDependencies": [],
2539
+ "files": [
2540
+ {
2541
+ "path": "components/linear/linear.tsx",
2542
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type LinearProps = {\n /** The workspace name at the top of the sidebar. */\n workspace?: string;\n /** Issues listed in the sidebar's team section. */\n team?: string;\n /** The issue identifier. */\n id?: string;\n title?: string;\n /** The issue body, above the activity. */\n body?: string;\n /** The status it starts in, and the one the agent moves it to. */\n status?: string;\n movesTo?: string;\n priority?: string;\n labels?: string[];\n assignee?: string;\n /** What a person asks in the comment box, typed in and posted. */\n request?: {author: string; body: string};\n /** The agent's comment, which arrives whole after a working indicator. */\n agent?: {name: string; body: string};\n thinkingFrames?: number;\n charactersPerSecond?: number;\n};\n\n/** Linear's dark theme, stacked by luminance the way Linear stacks it. */\nconst CANVAS = \"#121213\";\nconst PANEL = \"#161617\";\nconst SURFACE = \"#17181A\";\nconst HOVER = \"#1A1A1B\";\nconst INK = \"#FFFFFF\";\nconst SECOND = \"#E3E4E6\";\nconst FAINT = \"#959597\";\nconst EDGE = \"#1A1B1D\";\nconst FONT = '\"Inter Variable\", Inter, -apple-system, \"SF Pro Display\", \"Segoe UI\", sans-serif';\nconst ACCENT = \"#5E6AD2\";\nconst PROGRESS = \"#E2A336\";\n\n/**\n * A Linear workspace with an agent working an issue and closing it.\n *\n * The whole app is drawn because an issue tracker is a place, not a card: the\n * sidebar with its inbox and its teams, the issue in the middle, and the\n * properties rail on the right where status actually lives. That rail is why\n * the shot works. The comment is what the agent wrote, the status chip is what\n * it changed, and they sit far enough apart that the eye reads the sentence\n * first and catches the state change second.\n */\nexport const Linear = ({\n workspace = \"Odori\",\n team = \"Engineering\",\n id = \"ENG-128\",\n title = \"Export size differs between identical commits\",\n body = \"Two runs of the same commit produced files 40MB apart. Suspect the encoder defaults differ between machines.\",\n status = \"In Progress\",\n movesTo = \"Done\",\n priority = \"Urgent\",\n labels = [\"render\", \"bug\"],\n assignee = \"Odori\",\n request = {author: \"John Doe\", body: \"Can you work out why and pin whatever is drifting?\"},\n agent = {\n name: \"Odori\",\n body: \"Pinned both binaries and added a regression test. The two runs are byte identical now.\",\n },\n thinkingFrames = 30,\n charactersPerSecond = 34,\n}: LinearProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n /* The typing is the person's: they write the ask into the comment box and\n post it, and the agent's comment arrives whole. */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(request.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 16;\n const commentAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(request.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const posted = spring({frame, fps, delayInFrames: commentAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const landed = frame >= commentAt;\n const working = frame >= workingAt && frame < commentAt;\n const moved = interpolate(landed ? frame : 0, [0, 14], [0, 1], {easing: Easing.standard});\n const done = moved > 0.5;\n\n const sidebarRow = (label: string, active = false) => (\n <div\n key={label}\n style={{\n background: active ? HOVER : \"transparent\",\n borderRadius: px(6),\n color: active ? INK : FAINT,\n fontSize: px(19),\n margin: `${px(1)}px ${px(10)}px`,\n padding: `${px(8)}px ${px(12)}px`,\n }}\n >\n {label}\n </div>\n );\n\n const property = (label: string, value: ReactNode) => (\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12), padding: `${px(10)}px 0`}}>\n <span style={{color: FAINT, fontSize: px(18), width: px(110)}}>{label}</span>\n <span style={{color: INK, fontSize: px(18)}}>{value}</span>\n </div>\n );\n\n return (\n <Fill style={{background: \"#000000\", padding: px(60)}}>\n <div\n style={{\n background: CANVAS,\n borderRadius: px(14),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The sidebar. */}\n <div style={{background: PANEL, borderRight: `${px(1)}px solid ${EDGE}`, flex: \"none\", paddingTop: px(20), width: px(280)}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10), padding: `0 ${px(20)}px ${px(20)}px`}}>\n <span style={{background: ACCENT, borderRadius: px(7), height: px(28), width: px(28)}} />\n <span style={{color: INK, fontSize: px(21), fontWeight: 600}}>{workspace}</span>\n </div>\n {[\"Inbox\", \"My Issues\"].map((label) => sidebarRow(label))}\n <div style={{color: FAINT, fontSize: px(15), padding: `${px(20)}px ${px(22)}px ${px(6)}px`}}>{team}</div>\n {[\"Triage\", \"Active\", \"Backlog\"].map((label) => sidebarRow(label, label === \"Active\"))}\n </div>\n\n {/* The issue. */}\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div\n style={{\n alignItems: \"center\",\n borderBottom: `${px(1)}px solid ${EDGE}`,\n color: FAINT,\n display: \"flex\",\n fontSize: px(18),\n gap: px(10),\n padding: `${px(16)}px ${px(28)}px`,\n }}\n >\n <span>{team}</span>\n <span>›</span>\n <span style={{color: INK, fontFamily: brand.typography.mono}}>{id}</span>\n </div>\n\n <div style={{flex: 1, overflow: \"hidden\", padding: `${px(28)}px ${px(28)}px`}}>\n <div style={{color: INK, fontSize: px(34), fontWeight: 500, letterSpacing: \"-0.02em\"}}>{title}</div>\n <div style={{color: SECOND, fontSize: px(20), lineHeight: 1.6, marginTop: px(14)}}>{body}</div>\n\n <div style={{borderTop: `${px(1)}px solid ${EDGE}`, marginTop: px(26), paddingTop: px(22)}}>\n {sent ? (\n <div style={{marginBottom: px(16), opacity: asked, transform: `translateY(${(1 - asked) * px(8)}px)`}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10)}}>\n <span style={{background: \"#5E6AD2\", borderRadius: px(999), height: px(28), width: px(28)}} />\n <span style={{color: INK, fontSize: px(19), fontWeight: 600}}>{request.author}</span>\n </div>\n <div style={{color: INK, fontSize: px(20), lineHeight: 1.55, marginTop: px(10), paddingLeft: px(38)}}>\n {request.body}\n </div>\n </div>\n ) : null}\n\n <div\n style={{\n background: SURFACE,\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(10),\n opacity: working ? 1 : posted,\n padding: `${px(18)}px ${px(20)}px`,\n transform: `translateY(${(1 - (working ? 1 : posted)) * px(8)}px)`,\n }}\n >\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10)}}>\n <span style={{background: ACCENT, borderRadius: px(6), height: px(28), width: px(28)}} />\n <span style={{color: INK, fontSize: px(19), fontWeight: 600}}>{agent.name}</span>\n <span\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(999),\n color: FAINT,\n fontSize: px(14),\n padding: `${px(1)}px ${px(8)}px`,\n }}\n >\n agent\n </span>\n </div>\n <div style={{marginTop: px(12), minHeight: px(34), paddingLeft: px(28 + 10)}}>\n {working ? (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(7), height: px(26)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: FAINT,\n borderRadius: px(999),\n height: px(9),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(9),\n }}\n />\n ))}\n </span>\n ) : (\n <span style={{color: INK, fontSize: px(20), lineHeight: 1.55}}>{agent.body}</span>\n )}\n </div>\n </div>\n\n {landed ? (\n <div style={{color: FAINT, fontSize: px(18), marginTop: px(16), opacity: moved, paddingLeft: px(4)}}>\n {agent.name} changed status from {status} to {movesTo}\n </div>\n ) : null}\n\n {/* The comment box, where the ask is actually written. */}\n <div\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(10),\n color: sent ? FAINT : INK,\n fontSize: px(19),\n lineHeight: 1.55,\n marginTop: px(20),\n minHeight: px(30),\n padding: `${px(14)}px ${px(18)}px`,\n }}\n >\n {sent ? (\n \"Leave a comment...\"\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: INK,\n display: \"inline-block\",\n height: px(19),\n marginLeft: px(2),\n transform: `translateY(${px(3)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </div>\n </div>\n </div>\n </div>\n\n {/* The properties rail, where status actually lives. */}\n <div style={{borderLeft: `${px(1)}px solid ${EDGE}`, flex: \"none\", padding: `${px(24)}px ${px(24)}px`, width: px(380)}}>\n <div style={{color: FAINT, fontSize: px(15), letterSpacing: \"0.04em\", marginBottom: px(8)}}>PROPERTIES</div>\n {property(\n \"Status\",\n <span\n style={{\n alignItems: \"center\",\n color: done ? \"#A5AEF5\" : PROGRESS,\n display: \"inline-flex\",\n gap: px(9),\n transform: `scale(${1 + Math.sin(Math.min(1, moved) * Math.PI) * 0.07})`,\n }}\n >\n <span\n style={{\n background: done ? ACCENT : \"transparent\",\n border: `${px(2)}px solid ${done ? ACCENT : PROGRESS}`,\n borderRadius: px(999),\n height: px(13),\n width: px(13),\n }}\n />\n {done ? movesTo : status}\n </span>,\n )}\n {property(\"Priority\", priority)}\n {property(\"Assignee\", assignee)}\n {property(\n \"Labels\",\n <span style={{display: \"flex\", flexWrap: \"wrap\", gap: px(6)}}>\n {labels.map((label) => (\n <span\n key={label}\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(999),\n color: FAINT,\n fontSize: px(16),\n padding: `${px(4)}px ${px(11)}px`,\n }}\n >\n {label}\n </span>\n ))}\n </span>,\n )}\n </div>\n </div>\n </Fill>\n );\n};\n",
2543
+ "target": "videos/components/linear/linear.tsx"
2544
+ },
2545
+ {
2546
+ "path": "components/linear/linear.preview.tsx",
2547
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Linear} from \"./linear\";\n\nexport default defineComponentPreview({\n title: \"Linear\",\n category: \"Products\",\n description: \"A Linear workspace where an agent comments on an issue and moves it to done.\",\n component: Linear,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n workspace: {type: \"text\", defaultValue: \"Odori\", maxLength: 24},\n team: {type: \"text\", defaultValue: \"Engineering\", maxLength: 24},\n id: {type: \"text\", defaultValue: \"ENG-128\", maxLength: 16},\n title: {type: \"text\", defaultValue: \"Export size differs between identical commits\", maxLength: 72},\n status: {type: \"text\", defaultValue: \"In Progress\", maxLength: 20},\n movesTo: {type: \"text\", defaultValue: \"Done\", maxLength: 20},\n thinkingFrames: {type: \"number\", defaultValue: 30, min: 0, max: 90, step: 2},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Closing it\", props: {}},\n {\n name: \"Triage\",\n props: {\n id: \"ENG-204\",\n title: \"Studio pane unusable beside an editor\",\n body: \"At editor width the inspector takes half the window and the stage keeps the leftovers.\",\n status: \"Triage\",\n movesTo: \"In Progress\",\n priority: \"High\",\n labels: [\"studio\"],\n agent: {name: \"Odori\", body: \"Reproduced at 380px. The inspector is fixed width; making it draggable fixes it.\"},\n },\n },\n ],\n});\n",
2548
+ "target": "videos/components/linear/linear.preview.tsx"
2549
+ }
2550
+ ],
2551
+ "meta": {
2552
+ "kind": "component",
2553
+ "family": "Products",
2554
+ "namespaced": "@odori/linear",
2555
+ "contract": {
2556
+ "aspectRatios": [
2557
+ "16:9"
2558
+ ],
2559
+ "recommendedDurationInFrames": 270,
2560
+ "minimumDurationInFrames": 150,
2561
+ "entranceFrames": 14,
2562
+ "exitFrames": 12,
2563
+ "contentLimits": {
2564
+ "id": 16,
2565
+ "title": 72,
2566
+ "status": 20,
2567
+ "movesTo": 20,
2568
+ "workspace": 24,
2569
+ "team": 24
2570
+ },
2571
+ "reducedMotion": "status shown resolved, no chip transition",
2572
+ "requires": {
2573
+ "fonts": [
2574
+ "sans",
2575
+ "mono"
2576
+ ],
2577
+ "audio": []
2578
+ }
2579
+ }
2580
+ }
2581
+ },
1784
2582
  {
1785
2583
  "name": "log-stream",
1786
2584
  "description": "Paced runtime logs with levels and bounded scrolling.",
@@ -1793,7 +2591,7 @@
1793
2591
  },
1794
2592
  {
1795
2593
  "path": "components/log-stream/log-stream.preview.tsx",
1796
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {LogStream} from \"./log-stream\";\n\nexport default defineComponentPreview({\n title: \"Log stream\",\n category: \"Developer proof\",\n description: \"Paced runtime logs with levels and bounded scrolling.\",\n component: LogStream,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"odori export launch\"},\n window: {type: \"number\", defaultValue: 6, min: 3, max: 10},\n },\n examples: [\n {\n name: \"Export\",\n props: {\n entries: [\n {message: \"resolved manifest launch@2f9c\", level: \"info\"},\n {message: \"sampling 12 representative frames\", level: \"info\"},\n {message: \"scene proof runs 18f under its contract\", level: \"warn\"},\n {message: \"rendering 360 frames\", level: \"info\"},\n {message: \"mixing audio to -14 LUFS\", level: \"info\"},\n {message: \"wrote out/launch.mp4\", level: \"done\"},\n ],\n },\n },\n ],\n});\n",
2594
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {LogStream} from \"./log-stream\";\n\nexport default defineComponentPreview({\n title: \"Log stream\",\n category: \"Developer proof/Terminal\",\n description: \"Paced runtime logs with levels and bounded scrolling.\",\n component: LogStream,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"odori export launch\"},\n window: {type: \"number\", defaultValue: 6, min: 3, max: 10},\n },\n examples: [\n {\n name: \"Export\",\n props: {\n entries: [\n {message: \"resolved manifest launch@2f9c\", level: \"info\"},\n {message: \"sampling 12 representative frames\", level: \"info\"},\n {message: \"scene proof runs 18f under its contract\", level: \"warn\"},\n {message: \"rendering 360 frames\", level: \"info\"},\n {message: \"mixing audio to -14 LUFS\", level: \"info\"},\n {message: \"wrote out/launch.mp4\", level: \"done\"},\n ],\n },\n },\n ],\n});\n",
1797
2595
  "target": "videos/components/log-stream/log-stream.preview.tsx"
1798
2596
  }
1799
2597
  ],
@@ -1836,13 +2634,13 @@
1836
2634
  },
1837
2635
  {
1838
2636
  "path": "components/logo-orbit/logo-orbit.preview.tsx",
1839
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {LogoOrbit} from \"./logo-orbit\";\n\nexport default defineComponentPreview({\n title: \"Logo orbit\",\n category: \"Narrative\",\n description: \"Product identity surrounded by connected integrations.\",\n component: LogoOrbit,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n center: {type: \"text\", defaultValue: \"odori\", maxLength: 18},\n detail: {type: \"text\", defaultValue: \"one ecosystem\"},\n },\n examples: [\n {name: \"Ecosystem\", props: {around: [\"Next.js\", \"React\", \"Studio\", \"FFmpeg\", \"CI\", \"Slack\"]}},\n ],\n});\n",
2637
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {LogoOrbit} from \"./logo-orbit\";\n\nexport default defineComponentPreview({\n title: \"Logo orbit\",\n category: \"Brand\",\n description: \"Product identity surrounded by connected integrations.\",\n component: LogoOrbit,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n center: {type: \"text\", defaultValue: \"odori\", maxLength: 18},\n detail: {type: \"text\", defaultValue: \"one ecosystem\"},\n },\n examples: [\n {name: \"Ecosystem\", props: {around: [\"Next.js\", \"React\", \"Studio\", \"FFmpeg\", \"CI\", \"Slack\"]}},\n ],\n});\n",
1840
2638
  "target": "videos/components/logo-orbit/logo-orbit.preview.tsx"
1841
2639
  }
1842
2640
  ],
1843
2641
  "meta": {
1844
2642
  "kind": "component",
1845
- "family": "Narrative",
2643
+ "family": "Brand",
1846
2644
  "namespaced": "@odori/logo-orbit",
1847
2645
  "contract": {
1848
2646
  "aspectRatios": [
@@ -1881,13 +2679,13 @@
1881
2679
  },
1882
2680
  {
1883
2681
  "path": "components/marks-starter/marks-starter.preview.tsx",
1884
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {MarkAperture} from \"./marks-starter\";\n\nexport default defineComponentPreview({\n title: \"Marks starter\",\n category: \"Foundation\",\n description: \"Placeholder marks that draw themselves on, as editable SVG source.\",\n component: MarkAperture,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n size: {type: \"number\", defaultValue: 220, min: 48, max: 400},\n drawFrames: {type: \"number\", defaultValue: 24, min: 0, max: 60},\n },\n examples: [\n {name: \"Aperture\", props: {size: 220}},\n {name: \"Instant\", props: {size: 220, drawFrames: 0}},\n ],\n});\n",
2682
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {MarkAperture} from \"./marks-starter\";\n\nexport default defineComponentPreview({\n title: \"Marks starter\",\n category: \"Brand\",\n description: \"Placeholder marks that draw themselves on, as editable SVG source.\",\n component: MarkAperture,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n size: {type: \"number\", defaultValue: 220, min: 48, max: 400},\n drawFrames: {type: \"number\", defaultValue: 24, min: 0, max: 60},\n },\n examples: [\n {name: \"Aperture\", props: {size: 220}},\n {name: \"Instant\", props: {size: 220, drawFrames: 0}},\n ],\n});\n",
1885
2683
  "target": "videos/components/marks-starter/marks-starter.preview.tsx"
1886
2684
  }
1887
2685
  ],
1888
2686
  "meta": {
1889
2687
  "kind": "component",
1890
- "family": "Foundation",
2688
+ "family": "Brand",
1891
2689
  "namespaced": "@odori/marks-starter",
1892
2690
  "contract": {
1893
2691
  "aspectRatios": [
@@ -1959,7 +2757,7 @@
1959
2757
  },
1960
2758
  {
1961
2759
  "path": "components/metric-callout/metric-callout.preview.tsx",
1962
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {MetricCallout} from \"./metric-callout\";\n\nexport default defineComponentPreview({\n title: \"Metric callout\",\n category: \"Typography\",\n description: \"A single counted number with a supporting label.\",\n component: MetricCallout,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n value: {type: \"number\", defaultValue: 100, min: 0, max: 100000},\n label: {type: \"text\", defaultValue: \"frames rendered deterministically\"},\n suffix: {type: \"text\", defaultValue: \"%\"},\n },\n examples: [\n {name: \"Percentage\", props: {value: 100, suffix: \"%\", label: \"deterministic frames\"}},\n {name: \"Duration\", props: {value: 0.4, decimals: 1, suffix: \"s\", label: \"preview startup\"}},\n ],\n});\n",
2760
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {MetricCallout} from \"./metric-callout\";\n\nexport default defineComponentPreview({\n title: \"Metric callout\",\n category: \"Typography/Figures\",\n description: \"A single counted number with a supporting label.\",\n component: MetricCallout,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n value: {type: \"number\", defaultValue: 100, min: 0, max: 100000},\n label: {type: \"text\", defaultValue: \"frames rendered deterministically\"},\n suffix: {type: \"text\", defaultValue: \"%\"},\n },\n examples: [\n {name: \"Percentage\", props: {value: 100, suffix: \"%\", label: \"deterministic frames\"}},\n {name: \"Duration\", props: {value: 0.4, decimals: 1, suffix: \"s\", label: \"preview startup\"}},\n ],\n});\n",
1963
2761
  "target": "videos/components/metric-callout/metric-callout.preview.tsx"
1964
2762
  }
1965
2763
  ],
@@ -2003,13 +2801,13 @@
2003
2801
  },
2004
2802
  {
2005
2803
  "path": "components/modal-flow/modal-flow.preview.tsx",
2006
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ModalFlow} from \"./modal-flow\";\n\nconst Behind = () => (\n <div style={{background: \"#0b0b0b\", inset: 0, position: \"absolute\"}}>\n <div\n style={{\n color: \"#e5e5e5\",\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 44,\n left: 90,\n position: \"absolute\",\n top: 80,\n }}\n >\n Deployments\n </div>\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Modal flow\",\n category: \"Product UI\",\n description: \"Trigger, dialog, confirmation, and the state it resolves to.\",\n component: ModalFlow,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Deploy to production?\"},\n confirm: {type: \"text\", defaultValue: \"Deploy\"},\n resolved: {type: \"text\", defaultValue: \"Deployed\"},\n openAt: {type: \"number\", defaultValue: 18, min: 0, max: 60},\n confirmAt: {type: \"number\", defaultValue: 66, min: 20, max: 110},\n },\n examples: [\n {name: \"Default\", props: {children: <Behind />}},\n {name: \"Destructive\", props: {children: <Behind />, title: \"Delete project?\", confirm: \"Delete\", resolved: \"Deleted\"}},\n ],\n});\n",
2804
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ModalFlow} from \"./modal-flow\";\n\nconst Behind = () => (\n <div style={{background: \"#0b0b0b\", inset: 0, position: \"absolute\"}}>\n <div\n style={{\n color: \"#e5e5e5\",\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 44,\n left: 90,\n position: \"absolute\",\n top: 80,\n }}\n >\n Deployments\n </div>\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Modal flow\",\n category: \"Interface/Surfaces\",\n description: \"Trigger, dialog, confirmation, and the state it resolves to.\",\n component: ModalFlow,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Deploy to production?\"},\n confirm: {type: \"text\", defaultValue: \"Deploy\"},\n resolved: {type: \"text\", defaultValue: \"Deployed\"},\n openAt: {type: \"number\", defaultValue: 18, min: 0, max: 60},\n confirmAt: {type: \"number\", defaultValue: 66, min: 20, max: 110},\n },\n examples: [\n {name: \"Default\", props: {children: <Behind />}},\n {name: \"Destructive\", props: {children: <Behind />, title: \"Delete project?\", confirm: \"Delete\", resolved: \"Deleted\"}},\n ],\n});\n",
2007
2805
  "target": "videos/components/modal-flow/modal-flow.preview.tsx"
2008
2806
  }
2009
2807
  ],
2010
2808
  "meta": {
2011
2809
  "kind": "component",
2012
- "family": "Product UI",
2810
+ "family": "Interface",
2013
2811
  "namespaced": "@odori/modal-flow",
2014
2812
  "contract": {
2015
2813
  "aspectRatios": [
@@ -2050,7 +2848,7 @@
2050
2848
  },
2051
2849
  {
2052
2850
  "path": "components/notify/notify.preview.tsx",
2053
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {notify, type NotifyOptions} from \"./notify\";\n\nconst Wave = ({pitch, shimmer, peak}: NotifyOptions) => <CueWave cue={notify({pitch, shimmer, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Notify\",\n category: \"Sound\",\n description: \"A soft bell for a toast or a message.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n pitch: {type: \"select\", defaultValue: \"a5\", options: [\"f5\", \"a5\", \"c6\", \"e6\"]},\n shimmer: {type: \"number\", defaultValue: 0.5, min: 0, max: 1, step: 0.05},\n peak: {type: \"number\", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Pure\", props: {shimmer: 0}}],\n});\n",
2851
+ "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {notify, type NotifyOptions} from \"./notify\";\n\nconst Wave = ({pitch, shimmer, peak}: NotifyOptions) => <CueWave cue={notify({pitch, shimmer, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Notify\",\n category: \"Sound/Interface\",\n description: \"A soft bell for a toast or a message.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n pitch: {type: \"select\", defaultValue: \"a5\", options: [\"f5\", \"a5\", \"c6\", \"e6\"]},\n shimmer: {type: \"number\", defaultValue: 0.5, min: 0, max: 1, step: 0.05},\n peak: {type: \"number\", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Pure\", props: {shimmer: 0}}],\n});\n",
2054
2852
  "target": "videos/components/notify/notify.preview.tsx"
2055
2853
  }
2056
2854
  ],
@@ -2084,6 +2882,50 @@
2084
2882
  }
2085
2883
  }
2086
2884
  },
2885
+ {
2886
+ "name": "opencode",
2887
+ "description": "An open coding agent's terminal session, with the query typed under its wordmark.",
2888
+ "registryDependencies": [],
2889
+ "files": [
2890
+ {
2891
+ "path": "components/opencode/opencode.tsx",
2892
+ "content": "import {Easing, Fill, interpolate, useDesignScale, useFrame, useTyping, typingFrames} from \"odori\";\n\nexport type OpencodeProps = {\n /** What gets typed into the input under the wordmark. */\n query: string;\n /** The project the session is attached to, shown in the status bar. */\n project?: string;\n /** The model, shown in the status bar. */\n model?: string;\n /** The version beside the wordmark. */\n version?: string;\n /** Lines printed back once the query is sent. */\n response?: string[];\n charactersPerSecond?: number;\n};\n\n/**\n * opencode's own theme, taken from the palette the TUI ships with: the\n * primary is a warm peach rather than the blue a terminal usually reaches\n * for, and the surfaces climb #0a0a0a → #141414 → #1e1e1e.\n */\nconst PAGE = \"#0A0A0A\";\nconst PANEL = \"#141414\";\nconst ELEMENT = \"#1E1E1E\";\nconst INK = \"#EEEEEE\";\nconst FAINT = \"#808080\";\nconst EDGE = \"#484848\";\nconst EDGE_SUBTLE = \"#3C3C3C\";\nconst ACCENT = \"#FAB283\";\nconst SECONDARY = \"#5C9CF5\";\nconst SUCCESS = \"#7FD88F\";\n\n/**\n * An open coding agent's terminal session.\n *\n * Where a hosted tool leads with a welcome card, a TUI leads with its own\n * chrome: the wordmark at the top left, the session's facts pinned to a status\n * bar at the bottom, and the whole width given to the input between them. The\n * status bar is the detail worth keeping, because it is what makes a terminal\n * app feel like a place rather than a prompt.\n */\n/** A terminal sets its own face; this is the stack a TUI lands in. */\nconst FONT = '\"SF Mono\", \"JetBrains Mono\", \"Fira Code\", ui-monospace, Menlo, Consolas, monospace';\n\nexport const Opencode = ({\n query,\n project = \"odori\",\n model = \"claude-opus-4-5\",\n version = \"v0.4.2\",\n response = [],\n charactersPerSecond = 22,\n}: OpencodeProps) => {\n const frame = useFrame();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const from = 24;\n const typed = useTyping(query, {from, charactersPerSecond});\n const sentAt = from + typingFrames(query, {charactersPerSecond}) + 12;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const sent = frame >= sentAt;\n\n return (\n <Fill style={{background: PAGE, padding: px(90)}}>\n <div\n style={{\n background: PANEL,\n border: `${px(1)}px solid ${EDGE_SUBTLE}`,\n borderRadius: px(12),\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n <div style={{flex: 1, padding: `${px(34)}px ${px(36)}px`}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(16)}}>\n <span style={{color: INK, fontSize: px(34), fontWeight: 700, letterSpacing: \"-0.02em\"}}>\n open<span style={{color: ACCENT}}>code</span>\n </span>\n <span style={{color: FAINT, fontSize: px(20)}}>{version}</span>\n </div>\n\n <div\n style={{\n alignItems: \"center\",\n background: ELEMENT,\n border: `${px(1)}px solid ${sent ? EDGE_SUBTLE : EDGE}`,\n borderRadius: px(8),\n display: \"flex\",\n gap: px(14),\n marginTop: px(30),\n padding: `${px(18)}px ${px(20)}px`,\n }}\n >\n <span style={{color: ACCENT, fontSize: px(24)}}>❯</span>\n <span style={{color: INK, fontSize: px(24), whiteSpace: \"pre\"}}>\n {typed.text}\n {sent ? null : (\n <span\n style={{\n background: typed.caret ? ACCENT : \"transparent\",\n display: \"inline-block\",\n height: px(26),\n transform: `translateY(${px(5)}px)`,\n width: px(11),\n }}\n />\n )}\n </span>\n </div>\n\n {response.length > 0 ? (\n <div style={{display: \"grid\", gap: px(12), marginTop: px(28)}}>\n {response.map((line, index) => {\n const at = interpolate(frame, [sentAt + 8 + index * 13, sentAt + 20 + index * 13], [0, 1], {\n easing: Easing.standard,\n });\n return (\n <div key={line} style={{alignItems: \"baseline\", display: \"flex\", gap: px(12), opacity: at}}>\n <span style={{color: EDGE_SUBTLE, fontSize: px(20)}}>│</span>\n <span style={{color: INK, fontSize: px(21)}}>{line}</span>\n </div>\n );\n })}\n </div>\n ) : null}\n </div>\n\n {/* The status bar: what a TUI keeps in front of you the whole time. */}\n <div\n style={{\n alignItems: \"center\",\n borderTop: `${px(1)}px solid ${EDGE_SUBTLE}`,\n color: FAINT,\n display: \"flex\",\n fontSize: px(19),\n gap: px(20),\n padding: `${px(14)}px ${px(24)}px`,\n }}\n >\n <span style={{color: ACCENT, fontWeight: 700}}>{project}</span>\n <span style={{color: SECONDARY}}>{model}</span>\n <span style={{color: sent ? SUCCESS : FAINT, marginLeft: \"auto\"}}>{sent ? \"working\" : \"ctrl+c to quit\"}</span>\n </div>\n </div>\n </Fill>\n );\n};\n",
2893
+ "target": "videos/components/opencode/opencode.tsx"
2894
+ },
2895
+ {
2896
+ "path": "components/opencode/opencode.preview.tsx",
2897
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Opencode} from \"./opencode\";\n\nexport default defineComponentPreview({\n title: \"OpenCode\",\n category: \"Agents\",\n description: \"An open coding agent's terminal session, with the query typed under its wordmark.\",\n component: Opencode,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n query: {type: \"text\", defaultValue: \"find every place we still shell out to system ffmpeg\", maxLength: 90},\n project: {type: \"text\", defaultValue: \"odori\", maxLength: 28},\n model: {type: \"text\", defaultValue: \"claude-opus-4-5\", maxLength: 28},\n version: {type: \"text\", defaultValue: \"v0.4.2\", maxLength: 12},\n charactersPerSecond: {type: \"number\", defaultValue: 22, min: 8, max: 60, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {\n name: \"Working\",\n props: {\n query: \"run the render tests\",\n response: [\"packages/odori-cli/src/render.ts\", \"9 passed in 41s\", \"No system binaries were used.\"],\n },\n },\n ],\n});\n",
2898
+ "target": "videos/components/opencode/opencode.preview.tsx"
2899
+ }
2900
+ ],
2901
+ "meta": {
2902
+ "kind": "component",
2903
+ "family": "Agents",
2904
+ "namespaced": "@odori/opencode",
2905
+ "contract": {
2906
+ "aspectRatios": [
2907
+ "16:9"
2908
+ ],
2909
+ "recommendedDurationInFrames": 270,
2910
+ "minimumDurationInFrames": 120,
2911
+ "entranceFrames": 14,
2912
+ "exitFrames": 12,
2913
+ "contentLimits": {
2914
+ "query": 90,
2915
+ "project": 28,
2916
+ "model": 28,
2917
+ "version": 12
2918
+ },
2919
+ "reducedMotion": "query shown whole, caret still",
2920
+ "requires": {
2921
+ "fonts": [
2922
+ "mono"
2923
+ ],
2924
+ "audio": []
2925
+ }
2926
+ }
2927
+ }
2928
+ },
2087
2929
  {
2088
2930
  "name": "pop",
2089
2931
  "description": "A four frame bubble for something appearing.",
@@ -2096,7 +2938,7 @@
2096
2938
  },
2097
2939
  {
2098
2940
  "path": "components/pop/pop.preview.tsx",
2099
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {pop, type PopOptions} from \"./pop\";\n\nconst Wave = ({pitch, peak}: PopOptions) => <CueWave cue={pop({pitch, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Pop\",\n category: \"Sound\",\n description: \"A four frame bubble for something appearing.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"1s\"},\n controls: {\n pitch: {type: \"select\", defaultValue: \"e5\", options: [\"c5\", \"e5\", \"g5\", \"c6\"]},\n peak: {type: \"number\", defaultValue: 0.7, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Higher\", props: {pitch: \"c6\"}}],\n});\n",
2941
+ "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {pop, type PopOptions} from \"./pop\";\n\nconst Wave = ({pitch, peak}: PopOptions) => <CueWave cue={pop({pitch, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Pop\",\n category: \"Sound/Interface\",\n description: \"A four frame bubble for something appearing.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"1s\"},\n controls: {\n pitch: {type: \"select\", defaultValue: \"e5\", options: [\"c5\", \"e5\", \"g5\", \"c6\"]},\n peak: {type: \"number\", defaultValue: 0.7, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Higher\", props: {pitch: \"c6\"}}],\n});\n",
2100
2942
  "target": "videos/components/pop/pop.preview.tsx"
2101
2943
  }
2102
2944
  ],
@@ -2212,6 +3054,51 @@
2212
3054
  }
2213
3055
  }
2214
3056
  },
3057
+ {
3058
+ "name": "review-card",
3059
+ "description": "Who approved, whether the checks passed, and the one button that lands the change.",
3060
+ "registryDependencies": [],
3061
+ "files": [
3062
+ {
3063
+ "path": "components/review-card/review-card.tsx",
3064
+ "content": "import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type Reviewer = {\n /** Two initials, or a single glyph, drawn in the circle. */\n initials: string;\n /** Override the circle's fill. Defaults to a tint of the brand accent. */\n color?: string;\n};\n\nexport type ReviewCardProps = {\n title?: string;\n /** What the checks say once they have passed. */\n status?: string;\n /** Who approved, drawn as an overlapping stack. */\n reviewers?: Reviewer[];\n /** The button that completes the review. */\n action?: string;\n /** Frame the status resolves from pending to passed. */\n passAt?: number;\n /** Frame the action is pressed. */\n pressAt?: number;\n};\n\n/**\n * The card that says a change is ready, and the button that lands it.\n *\n * Every code product ends its story here, and the moment is carried by three\n * things at once: who signed off, whether the machines agreed, and one button\n * that is clearly the last step. The avatars overlap because a review is a\n * group act, and the check takes the leftmost position in the stack because\n * the machines are the last approver, not the first.\n *\n * The status resolves on a frame rather than arriving resolved: a card that\n * was always green never shows the thing worth showing, which is the moment it\n * turned green.\n */\nexport const ReviewCard = ({\n title = \"Ready to merge\",\n status = \"All checks passed\",\n reviewers = [{initials: \"MP\"}, {initials: \"AZ\"}],\n action = \"Squash and merge\",\n passAt = 18,\n pressAt,\n}: ReviewCardProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const passed = interpolate(frame, [passAt, passAt + 14], [0, 1], {easing: Easing.standard});\n const press =\n pressAt === undefined\n ? 0\n : interpolate(frame, [pressAt, pressAt + 4, pressAt + 12], [0, 1, 0], {easing: Easing.standard});\n const circle = 64 * scale;\n\n return (\n <Fill style={{alignItems: \"center\", background: brand.colors.surface, justifyContent: \"center\"}}>\n <div\n style={{\n background: brand.colors.background,\n borderRadius: 28 * scale,\n boxShadow: `0 ${28 * scale}px ${70 * scale}px color-mix(in srgb, ${brand.colors.foreground} 12%, transparent)`,\n display: \"grid\",\n gap: 26 * scale,\n minWidth: 720 * scale,\n padding: `${40 * scale}px ${44 * scale}px`,\n }}\n >\n <div style={{color: brand.colors.foreground, fontSize: 40 * scale, fontWeight: 560, letterSpacing: \"-0.02em\"}}>\n {title}\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", gap: 22 * scale}}>\n <div style={{display: \"flex\"}}>\n {/* The machines, then the people, each tucked under the last. */}\n <div\n style={{\n alignItems: \"center\",\n background: \"#0f8a4a\",\n borderRadius: \"50%\",\n border: `${3 * scale}px solid ${brand.colors.background}`,\n color: \"#ffffff\",\n display: \"flex\",\n fontSize: 30 * scale,\n height: circle,\n justifyContent: \"center\",\n opacity: passed,\n transform: `scale(${0.7 + passed * 0.3})`,\n width: circle,\n }}\n >\n ✓\n </div>\n {reviewers.map((reviewer, index) => (\n <div\n key={`${reviewer.initials}-${index}`}\n style={{\n alignItems: \"center\",\n background: reviewer.color ?? `color-mix(in srgb, ${brand.colors.accent} ${70 - index * 18}%, ${brand.colors.surface})`,\n borderRadius: \"50%\",\n border: `${3 * scale}px solid ${brand.colors.background}`,\n color: brand.colors.background,\n display: \"flex\",\n fontSize: 24 * scale,\n fontWeight: 560,\n height: circle,\n justifyContent: \"center\",\n marginLeft: -18 * scale,\n width: circle,\n }}\n >\n {reviewer.initials}\n </div>\n ))}\n </div>\n <div style={{color: brand.colors.foreground, fontSize: 32 * scale, opacity: 0.35 + passed * 0.65}}>\n {passed > 0.5 ? status : \"Running checks\"}\n </div>\n </div>\n\n <div\n style={{\n alignItems: \"center\",\n background: \"#0f8a4a\",\n borderRadius: 18 * scale,\n color: \"#ffffff\",\n display: \"flex\",\n filter: `brightness(${1 - press * 0.12})`,\n fontSize: 34 * scale,\n fontWeight: 500,\n height: 86 * scale,\n justifyContent: \"center\",\n opacity: 0.4 + passed * 0.6,\n transform: `scale(${1 - press * 0.02})`,\n }}\n >\n {action}\n </div>\n </div>\n </Fill>\n );\n};\n",
3065
+ "target": "videos/components/review-card/review-card.tsx"
3066
+ },
3067
+ {
3068
+ "path": "components/review-card/review-card.preview.tsx",
3069
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ReviewCard} from \"./review-card\";\n\nexport default defineComponentPreview({\n title: \"Review card\",\n category: \"Interface/Surfaces\",\n description: \"Who approved, whether the checks passed, and the one button that lands the change.\",\n component: ReviewCard,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Ready to merge\"},\n status: {type: \"text\", defaultValue: \"All checks passed\"},\n action: {type: \"text\", defaultValue: \"Squash and merge\"},\n passAt: {type: \"number\", defaultValue: 18, min: 0, max: 90},\n },\n examples: [\n {name: \"Merging\", props: {pressAt: 62}},\n {\n name: \"A larger review\",\n props: {reviewers: [{initials: \"MP\"}, {initials: \"AZ\"}, {initials: \"KL\"}], pressAt: 70},\n },\n ],\n});\n",
3070
+ "target": "videos/components/review-card/review-card.preview.tsx"
3071
+ }
3072
+ ],
3073
+ "meta": {
3074
+ "kind": "component",
3075
+ "family": "Interface",
3076
+ "namespaced": "@odori/review-card",
3077
+ "contract": {
3078
+ "aspectRatios": [
3079
+ "16:9",
3080
+ "9:16",
3081
+ "1:1"
3082
+ ],
3083
+ "recommendedDurationInFrames": 120,
3084
+ "minimumDurationInFrames": 48,
3085
+ "entranceFrames": 24,
3086
+ "exitFrames": 10,
3087
+ "contentLimits": {
3088
+ "reviewers": 4,
3089
+ "title": 34,
3090
+ "status": 30
3091
+ },
3092
+ "reducedMotion": "the card arrives already passed",
3093
+ "requires": {
3094
+ "fonts": [
3095
+ "sans"
3096
+ ],
3097
+ "audio": []
3098
+ }
3099
+ }
3100
+ }
3101
+ },
2215
3102
  {
2216
3103
  "name": "rolling-number",
2217
3104
  "description": "Metrics roll without width changes or digit jitter.",
@@ -2224,7 +3111,7 @@
2224
3111
  },
2225
3112
  {
2226
3113
  "path": "components/rolling-number/rolling-number.preview.tsx",
2227
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {RollingNumber} from \"./rolling-number\";\n\nexport default defineComponentPreview({\n title: \"Rolling number\",\n category: \"Typography\",\n description: \"Metrics roll without width changes or digit jitter.\",\n component: RollingNumber,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n from: {type: \"number\", defaultValue: 0, min: 0, max: 10000},\n to: {type: \"number\", defaultValue: 98.7, min: 0, max: 10000},\n decimals: {type: \"number\", defaultValue: 1, min: 0, max: 3},\n label: {type: \"text\", defaultValue: \"frames delivered on time\"},\n suffix: {type: \"text\", defaultValue: \"%\"},\n },\n examples: [\n {name: \"Percentage\", props: {to: 98.7, decimals: 1, suffix: \"%\", label: \"frames delivered on time\"}},\n {name: \"Count\", props: {to: 12480, decimals: 0, suffix: \"\", label: \"frames rendered\"}},\n ],\n});\n",
3114
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {RollingNumber} from \"./rolling-number\";\n\nexport default defineComponentPreview({\n title: \"Rolling number\",\n category: \"Typography/Figures\",\n description: \"Metrics roll without width changes or digit jitter.\",\n component: RollingNumber,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n from: {type: \"number\", defaultValue: 0, min: 0, max: 10000},\n to: {type: \"number\", defaultValue: 98.7, min: 0, max: 10000},\n decimals: {type: \"number\", defaultValue: 1, min: 0, max: 3},\n label: {type: \"text\", defaultValue: \"frames delivered on time\"},\n suffix: {type: \"text\", defaultValue: \"%\"},\n },\n examples: [\n {name: \"Percentage\", props: {to: 98.7, decimals: 1, suffix: \"%\", label: \"frames delivered on time\"}},\n {name: \"Count\", props: {to: 12480, decimals: 0, suffix: \"\", label: \"frames rendered\"}},\n ],\n});\n",
2228
3115
  "target": "videos/components/rolling-number/rolling-number.preview.tsx"
2229
3116
  }
2230
3117
  ],
@@ -2308,29 +3195,196 @@
2308
3195
  "target": "videos/components/screenshot-focus/screenshot-focus.tsx"
2309
3196
  },
2310
3197
  {
2311
- "path": "components/screenshot-focus/screenshot-focus.preview.tsx",
2312
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ScreenshotFocus} from \"./screenshot-focus\";\n\nexport default defineComponentPreview({\n title: \"Screenshot focus\",\n category: \"Media and canvas\",\n description: \"A camera move from the whole interface to one detail.\",\n component: ScreenshotFocus,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n zoom: {type: \"number\", defaultValue: 2.2, min: 1.2, max: 5, step: 0.1},\n at: {type: \"number\", defaultValue: 20, min: 0, max: 60},\n durationInFrames: {type: \"number\", defaultValue: 34, min: 10, max: 90},\n marker: {type: \"boolean\", defaultValue: true},\n caption: {type: \"text\", defaultValue: \"One deployment, 48 seconds\"},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Top left\", props: {focus: {x: 0.24, y: 0.3}, zoom: 3}},\n ],\n});\n",
2313
- "target": "videos/components/screenshot-focus/screenshot-focus.preview.tsx"
3198
+ "path": "components/screenshot-focus/screenshot-focus.preview.tsx",
3199
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ScreenshotFocus} from \"./screenshot-focus\";\n\nexport default defineComponentPreview({\n title: \"Screenshot focus\",\n category: \"Media/Footage\",\n description: \"A camera move from the whole interface to one detail.\",\n component: ScreenshotFocus,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n zoom: {type: \"number\", defaultValue: 2.2, min: 1.2, max: 5, step: 0.1},\n at: {type: \"number\", defaultValue: 20, min: 0, max: 60},\n durationInFrames: {type: \"number\", defaultValue: 34, min: 10, max: 90},\n marker: {type: \"boolean\", defaultValue: true},\n caption: {type: \"text\", defaultValue: \"One deployment, 48 seconds\"},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Top left\", props: {focus: {x: 0.24, y: 0.3}, zoom: 3}},\n ],\n});\n",
3200
+ "target": "videos/components/screenshot-focus/screenshot-focus.preview.tsx"
3201
+ }
3202
+ ],
3203
+ "meta": {
3204
+ "kind": "component",
3205
+ "family": "Media",
3206
+ "namespaced": "@odori/screenshot-focus",
3207
+ "contract": {
3208
+ "aspectRatios": [
3209
+ "16:9",
3210
+ "9:16",
3211
+ "1:1"
3212
+ ],
3213
+ "recommendedDurationInFrames": 120,
3214
+ "minimumDurationInFrames": 45,
3215
+ "entranceFrames": 0,
3216
+ "exitFrames": 0,
3217
+ "contentLimits": {
3218
+ "caption": 64
3219
+ },
3220
+ "reducedMotion": "the wide frame is held, with the marker in place",
3221
+ "requires": {
3222
+ "fonts": [
3223
+ "sans"
3224
+ ],
3225
+ "audio": []
3226
+ }
3227
+ }
3228
+ }
3229
+ },
3230
+ {
3231
+ "name": "settings-toggles",
3232
+ "description": "Switches flipping one at a time, the knob travelling and the track filling behind it.",
3233
+ "registryDependencies": [],
3234
+ "files": [
3235
+ {
3236
+ "path": "components/settings-toggles/settings-toggles.tsx",
3237
+ "content": "import {Easing, Fill, interpolate, spring, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\nexport type SettingsRow = {label: string; hint?: string; on?: boolean; flipsAt?: number};\n\nexport type SettingsTogglesProps = {\n title?: string;\n rows?: SettingsRow[];\n};\n\n/**\n * Switches being flipped in a settings panel.\n *\n * The knob travels and the track fills behind it, and the row's description\n * settles a frame later, which is the order the eye wants: the thing you\n * touched, then what it did. Flipping several at once would read as a\n * configuration screenshot rather than as someone changing their mind.\n */\nexport const SettingsToggles = ({\n title = \"Render\",\n rows = [\n {label: \"Reuse cached chunks\", hint: \"Skips frames that have not changed\", on: true},\n {label: \"Skip unchanged frames\", hint: \"Captures only what moved\", on: false, flipsAt: 34},\n {label: \"Normalize loudness\", hint: \"Mixes to the brand's target on export\", on: false, flipsAt: 62},\n {label: \"Open when finished\", hint: \"Reveals the file in your Downloads\", on: false},\n ],\n}: SettingsTogglesProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#09090B\", justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n background: \"#0C0C0E\",\n border: `${px(1)}px solid #1F1F23`,\n borderRadius: px(16),\n fontFamily: brand.typography.sans,\n maxWidth: px(1000),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(14)}px)`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n borderBottom: `${px(1)}px solid #1F1F23`,\n color: \"#FAFAFA\",\n fontSize: px(26),\n fontWeight: 500,\n padding: `${px(24)}px ${px(30)}px`,\n }}\n >\n {title}\n </div>\n\n {rows.map((row, index) => {\n const flip =\n row.flipsAt === undefined\n ? row.on\n ? 1\n : 0\n : spring({frame, fps, delayInFrames: row.flipsAt, stiffness: 260, damping: 20});\n const on = row.on ? 1 - flip * 0 : flip;\n return (\n <div\n key={row.label}\n style={{\n alignItems: \"center\",\n borderTop: index === 0 ? \"none\" : `${px(1)}px solid #16161A`,\n display: \"flex\",\n gap: px(24),\n padding: `${px(22)}px ${px(30)}px`,\n }}\n >\n <div style={{minWidth: 0}}>\n <div style={{color: \"#FAFAFA\", fontSize: px(23)}}>{row.label}</div>\n {row.hint ? (\n <div\n style={{\n color: \"#8A8A93\",\n fontSize: px(19),\n marginTop: px(5),\n // A beat behind the knob: the thing you touched, then\n // what it did.\n opacity: row.flipsAt === undefined ? 1 : 0.55 + on * 0.45,\n }}\n >\n {row.hint}\n </div>\n ) : null}\n </div>\n\n <span\n style={{\n background: `color-mix(in srgb, #FAFAFA ${on * 100}%, #26262C)`,\n borderRadius: px(999),\n display: \"inline-block\",\n flex: \"none\",\n height: px(36),\n marginLeft: \"auto\",\n position: \"relative\",\n width: px(64),\n }}\n >\n <span\n style={{\n background: on > 0.5 ? \"#09090B\" : \"#8A8A93\",\n borderRadius: px(999),\n height: px(26),\n left: px(5 + on * 28),\n position: \"absolute\",\n top: px(5),\n width: px(26),\n }}\n />\n </span>\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
3238
+ "target": "videos/components/settings-toggles/settings-toggles.tsx"
3239
+ },
3240
+ {
3241
+ "path": "components/settings-toggles/settings-toggles.preview.tsx",
3242
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SettingsToggles} from \"./settings-toggles\";\n\nexport default defineComponentPreview({\n title: \"Settings toggles\",\n category: \"Interface/Controls\",\n description: \"Switches flipping one at a time, the knob travelling and the track filling behind it.\",\n component: SettingsToggles,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {title: {type: \"text\", defaultValue: \"Render\", maxLength: 24}},\n examples: [\n {name: \"Default\", props: {}},\n {\n name: \"One switch\",\n props: {\n title: \"Preferences\",\n rows: [{label: \"Open Studio on start\", hint: \"Skips the terminal step\", on: false, flipsAt: 30}],\n },\n },\n ],\n});\n",
3243
+ "target": "videos/components/settings-toggles/settings-toggles.preview.tsx"
3244
+ }
3245
+ ],
3246
+ "meta": {
3247
+ "kind": "component",
3248
+ "family": "Interface",
3249
+ "namespaced": "@odori/settings-toggles",
3250
+ "contract": {
3251
+ "aspectRatios": [
3252
+ "16:9",
3253
+ "1:1"
3254
+ ],
3255
+ "recommendedDurationInFrames": 150,
3256
+ "minimumDurationInFrames": 75,
3257
+ "entranceFrames": 14,
3258
+ "exitFrames": 10,
3259
+ "contentLimits": {
3260
+ "title": 24
3261
+ },
3262
+ "reducedMotion": "switches shown in final state",
3263
+ "requires": {
3264
+ "fonts": [
3265
+ "sans"
3266
+ ],
3267
+ "audio": []
3268
+ }
3269
+ }
3270
+ }
3271
+ },
3272
+ {
3273
+ "name": "shared-axis",
3274
+ "description": "Related scenes travelling on one spatial axis.",
3275
+ "registryDependencies": [],
3276
+ "files": [
3277
+ {
3278
+ "path": "components/shared-axis/shared-axis.tsx",
3279
+ "content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useSceneTransition} from \"odori\";\n\nexport type SharedAxisProps = {\n children?: ReactNode;\n /** The axis the pair travels on. */\n axis?: \"x\" | \"y\" | \"z\";\n /** How far the move carries, as a fraction of the frame. */\n distance?: number;\n};\n\n/**\n * Two related scenes travelling on one axis.\n *\n * The claim a shared axis makes is that these two scenes are *the same kind of\n * thing*, one after another — a list and its detail, a before and its after.\n * That reads only if both halves move together on one line and fade across\n * each other, so the move is short, the fade is quick, and neither scene ever\n * translates in a direction the other does not.\n *\n * The `z` axis is the depth variant: forward for arriving, back for leaving,\n * which is the one to use when the second scene is *inside* the first rather\n * than beside it.\n *\n * ```tsx\n * <Scene id=\"detail\" duration=\"4s\" overlap=\"0.4s\">\n * <SharedAxis axis=\"x\">{content}</SharedAxis>\n * </Scene>\n * ```\n */\nexport const SharedAxis = ({children, axis = \"x\", distance = 0.12}: SharedAxisProps) => {\n const {entering, leaving} = useSceneTransition();\n\n const arriving = interpolate(entering, [0, 1], [0, 1], {easing: Easing.standard});\n const departing = interpolate(leaving, [0, 1], [0, 1], {easing: Easing.standard});\n const travelling = arriving < 1;\n\n // One line, one direction: arriving comes from behind the axis origin,\n // leaving continues past it.\n const shift = travelling ? (1 - arriving) * distance * 100 : -departing * distance * 100;\n const depth = travelling ? 0.94 + arriving * 0.06 : 1 + departing * 0.06;\n\n const transform =\n axis === \"z\"\n ? `scale(${depth})`\n : axis === \"x\"\n ? `translateX(${shift}%)`\n : `translateY(${shift}%)`;\n\n // The fade is fast at both ends so the two scenes are never both at half\n // strength, which is what makes a cross-fade look like a mistake.\n const opacity = travelling\n ? interpolate(arriving, [0, 0.45], [0, 1], {easing: Easing.standard})\n : interpolate(departing, [0.35, 1], [1, 0], {easing: Easing.standard});\n\n return <Fill style={{opacity, transform, transformOrigin: \"center\"}}>{children}</Fill>;\n};\n",
3280
+ "target": "videos/components/shared-axis/shared-axis.tsx"
3281
+ },
3282
+ {
3283
+ "path": "components/shared-axis/shared-axis.preview.tsx",
3284
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SharedAxis} from \"./shared-axis\";\n\nconst Panel = () => (\n <div\n style={{\n alignItems: \"center\",\n background: \"#0b0b0b\",\n color: \"#f5f5f5\",\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: \"ui-sans-serif, system-ui\",\n gap: 18,\n inset: 0,\n justifyContent: \"center\",\n position: \"absolute\",\n }}\n >\n <strong style={{fontSize: 84, letterSpacing: \"-0.04em\"}}>Deployment detail</strong>\n <span style={{color: \"#8f8f8f\", fontSize: 32}}>the same thing, one level in</span>\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Shared axis\",\n category: \"Motion\",\n description: \"Related scenes travelling on one spatial axis.\",\n component: SharedAxis,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n axis: {type: \"select\", defaultValue: \"x\", options: [\"x\", \"y\", \"z\"]},\n distance: {type: \"number\", defaultValue: 0.12, min: 0.02, max: 0.4, step: 0.02},\n },\n examples: [\n {name: \"Default\", props: {children: <Panel />}},\n {name: \"Depth\", props: {axis: \"z\", children: <Panel />}},\n ],\n});\n",
3285
+ "target": "videos/components/shared-axis/shared-axis.preview.tsx"
3286
+ }
3287
+ ],
3288
+ "meta": {
3289
+ "kind": "component",
3290
+ "family": "Motion",
3291
+ "namespaced": "@odori/shared-axis",
3292
+ "contract": {
3293
+ "aspectRatios": [
3294
+ "16:9",
3295
+ "9:16",
3296
+ "1:1"
3297
+ ],
3298
+ "recommendedDurationInFrames": 90,
3299
+ "minimumDurationInFrames": 30,
3300
+ "entranceFrames": 12,
3301
+ "exitFrames": 12,
3302
+ "contentLimits": {},
3303
+ "reducedMotion": "the scenes cross-fade without moving",
3304
+ "requires": {
3305
+ "fonts": [],
3306
+ "audio": []
3307
+ }
3308
+ }
3309
+ }
3310
+ },
3311
+ {
3312
+ "name": "sheet-panel",
3313
+ "description": "A side panel arriving while the page behind it gives way.",
3314
+ "registryDependencies": [],
3315
+ "files": [
3316
+ {
3317
+ "path": "components/sheet-panel/sheet-panel.tsx",
3318
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type SheetPanelProps = {\n /** The heading inside the panel. */\n title?: string;\n /** The line under it. */\n description?: string;\n /** Labelled rows the panel holds. */\n fields?: Array<{label: string; value: string}>;\n /** The button at the foot of the panel. */\n action?: string;\n /** The side it comes from. */\n side?: \"right\" | \"left\";\n /** Frame the panel starts opening. */\n openAt?: number;\n};\n\n/**\n * A side panel arriving over the page.\n *\n * The panel slides and the page behind it dims and recedes a fraction. That\n * second part is what sells it: a panel that arrives over a page which does\n * not react reads as two layers pasted together, and one where the page gives\n * way reads as depth.\n */\nexport const SheetPanel = ({\n title = \"Export settings\",\n description = \"Applies to this video only.\",\n fields = [\n {label: \"Format\", value: \"MP4\"},\n {label: \"Quality\", value: \"Studio\"},\n {label: \"Size\", value: \"1920 × 1080\"},\n {label: \"Destination\", value: \"Downloads\"},\n ],\n action = \"Export video\",\n side = \"right\",\n openAt = 20,\n}: SheetPanelProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 12], [0, 1], {easing: Easing.standard});\n const open = interpolate(frame, [openAt, openAt + 26], [0, 1], {easing: Easing.standard});\n const shift = (1 - open) * 100 * (side === \"right\" ? 1 : -1);\n\n return (\n <Fill style={{background: \"#09090B\", padding: px(70)}}>\n <div\n style={{\n borderRadius: px(16),\n fontFamily: brand.typography.sans,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n position: \"relative\",\n width: \"100%\",\n }}\n >\n {/* The page, giving way. */}\n <div\n style={{\n background: \"#0C0C0E\",\n border: `${px(1)}px solid #1F1F23`,\n borderRadius: px(16),\n height: \"100%\",\n padding: px(46),\n transform: `scale(${1 - open * 0.02})`,\n width: \"100%\",\n }}\n >\n <div style={{color: \"#FAFAFA\", fontSize: px(34), fontWeight: 500}}>Launch film</div>\n <div style={{color: \"#8A8A93\", fontSize: px(22), marginTop: px(12)}}>1920 × 1080 · 30fps · 12.00s</div>\n <div style={{display: \"grid\", gap: px(14), marginTop: px(36)}}>\n {[0, 1, 2].map((row) => (\n <div key={row} style={{background: \"#141418\", borderRadius: px(10), height: px(70)}} />\n ))}\n </div>\n </div>\n\n <span\n style={{\n background: \"#000000\",\n inset: 0,\n opacity: open * 0.55,\n position: \"absolute\",\n }}\n />\n\n <div\n style={{\n background: \"#0C0C0E\",\n borderLeft: side === \"right\" ? `${px(1)}px solid #26262C` : undefined,\n borderRight: side === \"left\" ? `${px(1)}px solid #26262C` : undefined,\n bottom: 0,\n boxShadow: `0 0 ${px(80)}px rgba(0,0,0,0.7)`,\n display: \"flex\",\n flexDirection: \"column\",\n padding: px(38),\n position: \"absolute\",\n right: side === \"right\" ? 0 : undefined,\n left: side === \"left\" ? 0 : undefined,\n top: 0,\n transform: `translateX(${shift}%)`,\n width: px(520),\n }}\n >\n <div style={{color: \"#FAFAFA\", fontSize: px(30), fontWeight: 500}}>{title}</div>\n <div style={{color: \"#8A8A93\", fontSize: px(20), marginTop: px(10)}}>{description}</div>\n\n <div style={{display: \"grid\", gap: px(20), marginTop: px(34)}}>\n {fields.map((field, index) => (\n <div\n key={field.label}\n style={{\n // Rows arrive just behind the panel, so the panel reads as\n // the thing moving and the contents as its cargo.\n opacity: interpolate(frame, [openAt + 12 + index * 4, openAt + 26 + index * 4], [0, 1], {\n easing: Easing.standard,\n }),\n }}\n >\n <div style={{color: \"#8A8A93\", fontSize: px(18)}}>{field.label}</div>\n <div\n style={{\n border: `${px(1)}px solid #26262C`,\n borderRadius: px(9),\n color: \"#FAFAFA\",\n fontSize: px(22),\n marginTop: px(7),\n padding: `${px(13)}px ${px(16)}px`,\n }}\n >\n {field.value}\n </div>\n </div>\n ))}\n </div>\n\n <div\n style={{\n background: \"#FAFAFA\",\n borderRadius: px(10),\n color: \"#09090B\",\n fontSize: px(22),\n fontWeight: 500,\n marginTop: \"auto\",\n padding: `${px(16)}px`,\n textAlign: \"center\",\n }}\n >\n {action}\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
3319
+ "target": "videos/components/sheet-panel/sheet-panel.tsx"
3320
+ },
3321
+ {
3322
+ "path": "components/sheet-panel/sheet-panel.preview.tsx",
3323
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SheetPanel} from \"./sheet-panel\";\n\nexport default defineComponentPreview({\n title: \"Sheet\",\n category: \"Interface/Chrome\",\n description: \"A side panel arriving while the page behind it gives way.\",\n component: SheetPanel,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Export settings\", maxLength: 32},\n description: {type: \"text\", defaultValue: \"Applies to this video only.\", maxLength: 48},\n action: {type: \"text\", defaultValue: \"Export video\", maxLength: 24},\n side: {type: \"select\", defaultValue: \"right\", options: [\"right\", \"left\"]},\n },\n examples: [\n {name: \"From the right\", props: {}},\n {name: \"From the left\", props: {side: \"left\"}},\n ],\n});\n",
3324
+ "target": "videos/components/sheet-panel/sheet-panel.preview.tsx"
3325
+ }
3326
+ ],
3327
+ "meta": {
3328
+ "kind": "component",
3329
+ "family": "Interface",
3330
+ "namespaced": "@odori/sheet-panel",
3331
+ "contract": {
3332
+ "aspectRatios": [
3333
+ "16:9",
3334
+ "1:1"
3335
+ ],
3336
+ "recommendedDurationInFrames": 150,
3337
+ "minimumDurationInFrames": 75,
3338
+ "entranceFrames": 14,
3339
+ "exitFrames": 10,
3340
+ "contentLimits": {
3341
+ "title": 32,
3342
+ "description": 48,
3343
+ "action": 24
3344
+ },
3345
+ "reducedMotion": "panel shown open, no slide",
3346
+ "requires": {
3347
+ "fonts": [
3348
+ "sans"
3349
+ ],
3350
+ "audio": []
3351
+ }
3352
+ }
3353
+ }
3354
+ },
3355
+ {
3356
+ "name": "skeleton-load",
3357
+ "description": "Placeholders shimmering, then resolving row by row into content of the same height.",
3358
+ "registryDependencies": [],
3359
+ "files": [
3360
+ {
3361
+ "path": "components/skeleton-load/skeleton-load.tsx",
3362
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type SkeletonRow = {title: string; meta: string};\n\nexport type SkeletonLoadProps = {\n /** The heading over the list. */\n title?: string;\n /** Rows that resolve, one after another. */\n rows?: SkeletonRow[];\n /** Frame the first row resolves. */\n resolveAt?: number;\n /** Frames between rows resolving. */\n stagger?: number;\n};\n\n/**\n * Placeholders resolving into content.\n *\n * The shimmer runs along the placeholders while they wait, and each row\n * resolves on its own beat rather than all at once, because data arriving in\n * a burst is what a mock does and data arriving in sequence is what a network\n * does. The placeholder and the real row are the same height, so nothing\n * shifts when the content lands.\n */\nexport const SkeletonLoad = ({\n title = \"Recent exports\",\n rows = [\n {title: \"launch.mp4\", meta: \"1920 × 1080 · 12.4 MB · 2m ago\"},\n {title: \"social-announcement.mp4\", meta: \"1080 × 1920 · 8.1 MB · 14m ago\"},\n {title: \"changelog.webm\", meta: \"1920 × 1080 · 3.2 MB · 1h ago\"},\n {title: \"feature-tour.mp4\", meta: \"1920 × 1080 · 18.9 MB · 3h ago\"},\n ],\n resolveAt = 40,\n stagger = 12,\n}: SkeletonLoadProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#09090B\", justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n background: \"#0C0C0E\",\n border: `${px(1)}px solid #1F1F23`,\n borderRadius: px(16),\n fontFamily: brand.typography.sans,\n maxWidth: px(1040),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(14)}px)`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n borderBottom: `${px(1)}px solid #1F1F23`,\n color: \"#FAFAFA\",\n fontSize: px(26),\n fontWeight: 500,\n padding: `${px(24)}px ${px(30)}px`,\n }}\n >\n {title}\n </div>\n\n {rows.map((row, index) => {\n const at = resolveAt + index * stagger;\n const resolved = interpolate(frame, [at, at + 12], [0, 1], {easing: Easing.standard});\n // The shimmer is a band travelling along the placeholder, on a two\n // second cycle so it reads as waiting rather than as blinking.\n const sweep = ((frame + index * 8) % 60) / 60;\n return (\n <div\n key={row.title}\n style={{\n alignItems: \"center\",\n borderTop: index === 0 ? \"none\" : `${px(1)}px solid #16161A`,\n display: \"flex\",\n gap: px(20),\n height: px(96),\n padding: `0 ${px(30)}px`,\n }}\n >\n <span\n style={{\n background:\n resolved > 0.5\n ? \"#26262C\"\n : `linear-gradient(90deg, #17171B ${sweep * 100 - 25}%, #26262C ${sweep * 100}%, #17171B ${sweep * 100 + 25}%)`,\n borderRadius: px(9),\n flex: \"none\",\n height: px(48),\n width: px(48),\n }}\n />\n <div style={{flex: 1, minWidth: 0, position: \"relative\"}}>\n <div style={{opacity: 1 - resolved, position: resolved > 0.99 ? \"absolute\" : \"static\"}}>\n <span\n style={{\n background: `linear-gradient(90deg, #17171B ${sweep * 100 - 25}%, #26262C ${sweep * 100}%, #17171B ${sweep * 100 + 25}%)`,\n borderRadius: px(6),\n display: \"block\",\n height: px(22),\n width: \"42%\",\n }}\n />\n <span\n style={{\n background: `linear-gradient(90deg, #141418 ${sweep * 100 - 25}%, #202026 ${sweep * 100}%, #141418 ${sweep * 100 + 25}%)`,\n borderRadius: px(6),\n display: \"block\",\n height: px(17),\n marginTop: px(10),\n width: \"64%\",\n }}\n />\n </div>\n <div style={{opacity: resolved, position: resolved > 0.99 ? \"static\" : \"absolute\", top: 0}}>\n <div style={{color: \"#FAFAFA\", fontFamily: brand.typography.mono, fontSize: px(22)}}>{row.title}</div>\n <div style={{color: \"#8A8A93\", fontSize: px(19), marginTop: px(7)}}>{row.meta}</div>\n </div>\n </div>\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
3363
+ "target": "videos/components/skeleton-load/skeleton-load.tsx"
3364
+ },
3365
+ {
3366
+ "path": "components/skeleton-load/skeleton-load.preview.tsx",
3367
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SkeletonLoad} from \"./skeleton-load\";\n\nexport default defineComponentPreview({\n title: \"Skeleton\",\n category: \"Interface/Feedback\",\n description: \"Placeholders shimmering, then resolving row by row into content of the same height.\",\n component: SkeletonLoad,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Recent exports\", maxLength: 32},\n resolveAt: {type: \"number\", defaultValue: 40, min: 10, max: 120, step: 5},\n stagger: {type: \"number\", defaultValue: 12, min: 0, max: 40, step: 2},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"All at once\", props: {stagger: 0, resolveAt: 50}},\n ],\n});\n",
3368
+ "target": "videos/components/skeleton-load/skeleton-load.preview.tsx"
2314
3369
  }
2315
3370
  ],
2316
3371
  "meta": {
2317
3372
  "kind": "component",
2318
- "family": "Media and canvas",
2319
- "namespaced": "@odori/screenshot-focus",
3373
+ "family": "Interface",
3374
+ "namespaced": "@odori/skeleton-load",
2320
3375
  "contract": {
2321
3376
  "aspectRatios": [
2322
3377
  "16:9",
2323
- "9:16",
2324
3378
  "1:1"
2325
3379
  ],
2326
- "recommendedDurationInFrames": 120,
2327
- "minimumDurationInFrames": 45,
2328
- "entranceFrames": 0,
2329
- "exitFrames": 0,
3380
+ "recommendedDurationInFrames": 150,
3381
+ "minimumDurationInFrames": 75,
3382
+ "entranceFrames": 14,
3383
+ "exitFrames": 10,
2330
3384
  "contentLimits": {
2331
- "caption": 64
3385
+ "title": 32
2332
3386
  },
2333
- "reducedMotion": "the wide frame is held, with the marker in place",
3387
+ "reducedMotion": "content shown resolved, no shimmer",
2334
3388
  "requires": {
2335
3389
  "fonts": [
2336
3390
  "sans"
@@ -2341,39 +3395,42 @@
2341
3395
  }
2342
3396
  },
2343
3397
  {
2344
- "name": "shared-axis",
2345
- "description": "Related scenes travelling on one spatial axis.",
3398
+ "name": "slack",
3399
+ "description": "A Slack channel where a question is answered by an app in its thread.",
2346
3400
  "registryDependencies": [],
2347
3401
  "files": [
2348
3402
  {
2349
- "path": "components/shared-axis/shared-axis.tsx",
2350
- "content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useSceneTransition} from \"odori\";\n\nexport type SharedAxisProps = {\n children?: ReactNode;\n /** The axis the pair travels on. */\n axis?: \"x\" | \"y\" | \"z\";\n /** How far the move carries, as a fraction of the frame. */\n distance?: number;\n};\n\n/**\n * Two related scenes travelling on one axis.\n *\n * The claim a shared axis makes is that these two scenes are *the same kind of\n * thing*, one after another — a list and its detail, a before and its after.\n * That reads only if both halves move together on one line and fade across\n * each other, so the move is short, the fade is quick, and neither scene ever\n * translates in a direction the other does not.\n *\n * The `z` axis is the depth variant: forward for arriving, back for leaving,\n * which is the one to use when the second scene is *inside* the first rather\n * than beside it.\n *\n * ```tsx\n * <Scene id=\"detail\" duration=\"4s\" overlap=\"0.4s\">\n * <SharedAxis axis=\"x\">{content}</SharedAxis>\n * </Scene>\n * ```\n */\nexport const SharedAxis = ({children, axis = \"x\", distance = 0.12}: SharedAxisProps) => {\n const {entering, leaving} = useSceneTransition();\n\n const arriving = interpolate(entering, [0, 1], [0, 1], {easing: Easing.standard});\n const departing = interpolate(leaving, [0, 1], [0, 1], {easing: Easing.standard});\n const travelling = arriving < 1;\n\n // One line, one direction: arriving comes from behind the axis origin,\n // leaving continues past it.\n const shift = travelling ? (1 - arriving) * distance * 100 : -departing * distance * 100;\n const depth = travelling ? 0.94 + arriving * 0.06 : 1 + departing * 0.06;\n\n const transform =\n axis === \"z\"\n ? `scale(${depth})`\n : axis === \"x\"\n ? `translateX(${shift}%)`\n : `translateY(${shift}%)`;\n\n // The fade is fast at both ends so the two scenes are never both at half\n // strength, which is what makes a cross-fade look like a mistake.\n const opacity = travelling\n ? interpolate(arriving, [0, 0.45], [0, 1], {easing: Easing.standard})\n : interpolate(departing, [0.35, 1], [1, 0], {easing: Easing.standard});\n\n return <Fill style={{opacity, transform, transformOrigin: \"center\"}}>{children}</Fill>;\n};\n",
2351
- "target": "videos/components/shared-axis/shared-axis.tsx"
3403
+ "path": "components/slack/slack.tsx",
3404
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type SlackPost = {\n author: string;\n body: string;\n time?: string;\n /** Marks the sender as an app rather than a person. */\n app?: boolean;\n /** The avatar tile colour. */\n color?: string;\n /** A name mentioned at the head of the message, drawn as a link. */\n mention?: string;\n};\n\nexport type SlackProps = {\n workspace?: string;\n /** Channels in the rail. A string is public; an object can be private. */\n channels?: (string | {name: string; private?: boolean})[];\n directMessages?: string[];\n channel?: string;\n /** The message that lands and gets answered. */\n ask?: SlackPost;\n /** A reaction on that message. */\n reaction?: {emoji: string; count: number; mine?: boolean};\n /** The app's reply, typed into the thread under it. */\n reply?: SlackPost;\n thinkingFrames?: number;\n theme?: \"aubergine\" | \"dark\";\n charactersPerSecond?: number;\n};\n\n/**\n * Slack's two looks, measured off the running client.\n *\n * The selected channel is a saturated aubergine that Slack keeps in both\n * themes, not the muted one a screenshot suggests, and the sidebar's text is a\n * lavender at eight tenths rather than a flat grey.\n */\n/**\n * The composer's icons, on Slack's own 20 grid.\n *\n * Drawn by eye these never landed: the set is specific (underline and a\n * separate code block, no camera or microphone) and so is the weight.\n */\nconst COMPOSER_ICONS = {\n bold:\n \"M4 2.75A.75.75 0 0 1 4.75 2h6.343a3.91 3.91 0 0 1 3.88 3.449A2 2 0 0 1 15 5.84l.001.067a3.9 3.9 0 0 1-1.551 3.118A4.627 4.627 0 0 1 11.875 18H4.75a.75.75 0 0 1-.75-.75V9.5a.8.8 0 0 1 .032-.218A.8.8 0 0 1 4 9.065zm2.5 5.565h3.593a2.157 2.157 0 1 0 0-4.315H6.5zm4.25 1.935H6.5v5.5h4.25a2.75 2.75 0 1 0 0-5.5\",\n italic:\n \"M7 2.75A.75.75 0 0 1 7.75 2h7.5a.75.75 0 0 1 0 1.5H12.3l-2.6 13h2.55a.75.75 0 0 1 0 1.5h-7.5a.75.75 0 0 1 0-1.5H7.7l2.6-13H7.75A.75.75 0 0 1 7 2.75\",\n underline:\n \"M17.25 17.12a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5zM14.5 1.63a.75.75 0 0 1 .75.75v8a5.25 5.25 0 1 1-10.5 0v-8a.75.75 0 0 1 1.5 0v8a3.75 3.75 0 0 0 7.5 0v-8a.75.75 0 0 1 .75-.75\",\n strike:\n \"M11.721 3.84c-.91-.334-2.028-.36-3.035-.114-1.51.407-2.379 1.861-2.164 3.15C6.718 8.051 7.939 9.5 11.5 9.5l.027.001h5.723a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5h3.66c-.76-.649-1.216-1.468-1.368-2.377-.347-2.084 1.033-4.253 3.265-4.848l.007-.002.007-.002c1.252-.307 2.68-.292 3.915.16 1.252.457 2.337 1.381 2.738 2.874a.75.75 0 0 1-1.448.39c-.25-.925-.91-1.528-1.805-1.856m2.968 9.114a.75.75 0 1 0-1.378.59c.273.64.186 1.205-.13 1.674-.333.492-.958.925-1.82 1.137-.989.243-1.991.165-3.029-.124-.93-.26-1.613-.935-1.858-1.845a.75.75 0 0 0-1.448.39c.388 1.441 1.483 2.503 2.903 2.9 1.213.338 2.486.456 3.79.135 1.14-.28 2.12-.889 2.704-1.753.6-.888.743-1.992.266-3.104\",\n link:\n \"M12.306 3.756a2.75 2.75 0 0 1 3.889 0l.05.05a2.75 2.75 0 0 1 0 3.889l-3.18 3.18a2.75 2.75 0 0 1-3.98-.095l-.03-.034a.75.75 0 0 0-1.11 1.009l.03.034a4.25 4.25 0 0 0 6.15.146l3.18-3.18a4.25 4.25 0 0 0 0-6.01l-.05-.05a4.25 4.25 0 0 0-6.01 0L9.47 4.47a.75.75 0 1 0 1.06 1.06zm-4.611 12.49a2.75 2.75 0 0 1-3.89 0l-.05-.051a2.75 2.75 0 0 1 0-3.89l3.18-3.179a2.75 2.75 0 0 1 3.98.095l.03.034a.75.75 0 1 0 1.11-1.01l-.03-.033a4.25 4.25 0 0 0-6.15-.146l-3.18 3.18a4.25 4.25 0 0 0 0 6.01l.05.05a4.25 4.25 0 0 0 6.01 0l1.775-1.775a.75.75 0 0 0-1.06-1.06z\",\n ordered:\n \"M3.792 2.094A.5.5 0 0 1 4 2.5V6h1a.5.5 0 1 1 0 1H2a.5.5 0 1 1 0-1h1V3.194l-.842.28a.5.5 0 0 1-.316-.948l1.5-.5a.5.5 0 0 1 .45.068M7.75 3.5a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zM7 10.75a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10a.75.75 0 0 1-.75-.75m0 6.5a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10a.75.75 0 0 1-.75-.75m-4.293-3.36a1 1 0 0 1 .793-.39c.49 0 .75.38.75.75 0 .064-.033.194-.173.409a5 5 0 0 1-.594.711c-.256.267-.552.548-.87.848l-.088.084a42 42 0 0 0-.879.845A.5.5 0 0 0 2 18h3a.5.5 0 0 0 0-1H3.242l.058-.055c.316-.298.629-.595.904-.882a6 6 0 0 0 .711-.859c.18-.277.335-.604.335-.954 0-.787-.582-1.75-1.75-1.75a2 2 0 0 0-1.81 1.147.5.5 0 1 0 .905.427 1 1 0 0 1 .112-.184\",\n bulleted:\n \"M4 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10A.75.75 0 0 1 7 3m.75 6.25a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zm0 7a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zM3 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2m0 7a1 1 0 1 0 0-2 1 1 0 0 0 0 2\",\n quote:\n \"M3.5 2.75a.75.75 0 0 0-1.5 0v14.5a.75.75 0 0 0 1.5 0zM6.75 3a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5zM6 10.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H6.75a.75.75 0 0 1-.75-.75m.75 5.25a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5z\",\n code:\n \"M12.058 3.212c.396.12.62.54.5.936L8.87 16.29a.75.75 0 1 1-1.435-.436l3.686-12.143a.75.75 0 0 1 .936-.5M5.472 6.24a.75.75 0 0 1 .005 1.06l-2.67 2.693 2.67 2.691a.75.75 0 1 1-1.065 1.057l-3.194-3.22a.75.75 0 0 1 0-1.056l3.194-3.22a.75.75 0 0 1 1.06-.005m9.044 1.06a.75.75 0 1 1 1.065-1.056l3.194 3.221a.75.75 0 0 1 0 1.057l-3.194 3.219a.75.75 0 0 1-1.065-1.057l2.67-2.69z\",\n codeblock:\n \"M9.212 2.737a.75.75 0 1 0-1.424-.474l-2.5 7.5a.75.75 0 0 0 1.424.474zm6.038.265a.75.75 0 0 0 0 1.5h2a.25.25 0 0 1 .25.25v11.5a.25.25 0 0 1-.25.25h-13a.25.25 0 0 1-.25-.25v-3.5a.75.75 0 0 0-1.5 0v3.5c0 .966.784 1.75 1.75 1.75h13a1.75 1.75 0 0 0 1.75-1.75v-11.5a1.75 1.75 0 0 0-1.75-1.75zm-3.69.5a.75.75 0 1 0-1.12.996l1.556 1.754-1.556 1.75a.75.75 0 1 0 1.12.997l2-2.249a.75.75 0 0 0 0-.996zM3.999 9.061a.75.75 0 0 1-1.058-.062l-2-2.249a.75.75 0 0 1 0-.996l2-2.252a.75.75 0 1 1 1.12.996L2.504 6.252l1.557 1.75a.75.75 0 0 1-.062 1.059\",\n attach:\n \"M10.75 3.25a.75.75 0 0 0-1.5 0v6H3.251L3.25 10v-.75a.75.75 0 0 0 0 1.5V10v.75h6v6a.75.75 0 0 0 1.5 0v-6h6a.75.75 0 0 0 0-1.5h-6z\",\n format:\n \"M6.941 3.952c-.459-1.378-2.414-1.363-2.853.022l-4.053 12.8a.75.75 0 0 0 1.43.452l1.101-3.476h6.06l1.163 3.487a.75.75 0 1 0 1.423-.474zm1.185 8.298L5.518 4.427 3.041 12.25zm6.198-5.537a4.74 4.74 0 0 1 3.037-.081A3.74 3.74 0 0 1 20 10.208V17a.75.75 0 0 1-1.5 0v-.745a8 8 0 0 1-2.847 1.355 3 3 0 0 1-3.15-1.143C10.848 14.192 12.473 11 15.287 11H18.5v-.792c0-.984-.641-1.853-1.581-2.143a3.24 3.24 0 0 0-2.077.056l-.242.089a2.22 2.22 0 0 0-1.34 1.382l-.048.145a.75.75 0 0 1-1.423-.474l.048-.145a3.72 3.72 0 0 1 2.244-2.315zM18.5 12.5h-3.213c-1.587 0-2.504 1.801-1.57 3.085.357.491.98.717 1.572.57a6.5 6.5 0 0 0 2.47-1.223l.741-.593z\",\n emoji:\n \"M2.5 10a7.5 7.5 0 1 1 15 0 7.5 7.5 0 0 1-15 0M10 1a9 9 0 1 0 0 18 9 9 0 0 0 0-18M7.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3M14 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m-6.385 3.766a.75.75 0 1 0-1.425.468C6.796 14.08 8.428 15 10.027 15s3.23-.92 3.838-2.766a.75.75 0 1 0-1.425-.468c-.38 1.155-1.38 1.734-2.413 1.734s-2.032-.58-2.412-1.734\",\n mention:\n \"M2.5 10a7.5 7.5 0 1 1 15 0v.645c0 1.024-.83 1.855-1.855 1.855a1.145 1.145 0 0 1-1.145-1.145V6.75a.75.75 0 0 0-1.494-.098 4.5 4.5 0 1 0 .465 6.212A2.64 2.64 0 0 0 15.646 14 3.355 3.355 0 0 0 19 10.645V10a9 9 0 1 0-3.815 7.357.75.75 0 1 0-.865-1.225A7.5 7.5 0 0 1 2.5 10m7.5 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6\",\n send:\n \"M1.5 2.106c0-.462.498-.754.901-.528l15.7 7.714a.73.73 0 0 1 .006 1.307L2.501 18.46l-.07.017a.754.754 0 0 1-.931-.733v-4.572c0-1.22.971-2.246 2.213-2.268l6.547-.17c.27-.01.75-.243.75-.797 0-.553-.5-.795-.75-.795l-6.547-.171C2.47 8.95 1.5 7.924 1.5 6.704z\",\n} as const;\n\n/**\n * The channel hash, taken from inside Slack's own channels mark and scaled\n * to fill the grid. Typing a \"#\" gives the font's hash, which is upright\n * where Slack's is raked, and lands a size small beside the name.\n */\nconst CHANNEL_HASH =\n \"M8.652 5.465a.751.751 0 0 1 1.478.256l-.268 1.556h1.365l.313-1.81a.75.75 0 0 1 1.478.256l-.269 1.554h1.658a.751.751 0 0 1 0 1.5h-1.915l-.475 2.753H13.8a.75.75 0 0 1 0 1.5h-2.042l-.26 1.503a.75.75 0 0 1-1.478-.255l.215-1.248H8.869l-.26 1.501a.75.75 0 0 1-1.478-.255l.215-1.246H5.593a.75.75 0 0 1 .001-1.5h2.012l.475-2.753H6.2a.75.75 0 0 1 0-1.5h2.14zM9.128 11.53h1.366l.474-2.753H9.603z\";\n\n/**\n * The add-reaction control's face. Slack layers a solid disc beneath this\n * one; filling both gives a blob, and this path already carries the ring,\n * the plus, the eyes and the mouth.\n */\nconst ADD_REACTION =\n \"M15.5 1a.75.75 0 0 1 .75.75v2h2a.75.75 0 0 1 0 1.5h-2v2a.75.75 0 0 1-1.5 0v-2h-2a.75.75 0 0 1 0-1.5h2v-2A.75.75 0 0 1 15.5 1Zm-13 10a6.5 6.5 0 0 1 7.166-6.466.75.75 0 0 0 .152-1.493 8 8 0 1 0 7.14 7.139.75.75 0 0 0-1.492.152A6.525 6.525 0 0 1 15.5 11a6.5 6.5 0 1 1-13 0Zm4.25-.5a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm4.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9 15c1.277 0 2.553-.724 3.06-2.173.148-.426-.209-.827-.66-.827H6.6c-.452 0-.808.4-.66.827C6.448 14.276 7.724 15 9 15Z\";\n\n/** Slack's blue for a reaction you are part of: the ring and the ink. */\n/**\n * Slack's default picture is an initial on a flat ground in a rounded\n * square, at a shade over half the box and in a medium rather than a bold.\n */\nconst DEFAULT_AVATAR = \"#2B5C8A\";\n\nconst REACTED_WASH = \"#E3F8FF\";\nconst REACTED_INK = \"#1264A3\";\n\n/**\n * The rail Slack puts left of everything: the workspace, then Home, DMs,\n * Activity, Files and More, each an icon over its own label. Slack's own\n * paths, on its 20 grid.\n */\nconst RAIL = [\n {label: \"Home\", d: \"m3 7.649-.33.223a.75.75 0 0 1-.84-1.244l7.191-4.852a1.75 1.75 0 0 1 1.958 0l7.19 4.852a.75.75 0 1 1-.838 1.244L17 7.649v7.011c0 2.071-1.679 3.84-3.75 3.84h-6.5C4.679 18.5 3 16.731 3 14.66zM11 11a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1z\"},\n {label: \"DMs\", d: \"M7.675 6.468a4.75 4.75 0 1 1 8.807 3.441.75.75 0 0 0-.067.489l.379 1.896-1.896-.38a.75.75 0 0 0-.489.068 5 5 0 0 1-.648.273.75.75 0 1 0 .478 1.422q.314-.105.611-.242l2.753.55a.75.75 0 0 0 .882-.882l-.55-2.753A6.25 6.25 0 1 0 6.23 6.064a.75.75 0 1 0 1.445.404M6.5 8.5a5 5 0 0 0-4.57 7.03l-.415 2.073a.75.75 0 0 0 .882.882l2.074-.414A5 5 0 1 0 6.5 8.5m-3.5 5a3.5 3.5 0 1 1 1.91 3.119.75.75 0 0 0-.49-.068l-1.214.243.243-1.215a.75.75 0 0 0-.068-.488A3.5 3.5 0 0 1 3 13.5\"},\n {label: \"Activity\", d: \"M9.357 3.256c-.157.177-.31.504-.36 1.062l-.05.558-.55.11c-1.024.204-1.691.71-2.145 1.662-.485 1.016-.736 2.566-.752 4.857l-.002.307-.217.217-2.07 2.077c-.145.164-.193.293-.206.374a.3.3 0 0 0 .034.199c.069.12.304.321.804.321h4.665l.07.672c.034.327.17.668.4.915.214.232.536.413 1.036.413.486 0 .802-.178 1.013-.41.227-.247.362-.588.396-.916l.069-.674h4.663c.5 0 .735-.202.804-.321a.3.3 0 0 0 .034-.199c-.013-.08-.061-.21-.207-.374l-2.068-2.077-.216-.217-.002-.307c-.015-2.291-.265-3.841-.75-4.857-.455-.952-1.123-1.458-2.147-1.663l-.549-.11-.05-.557c-.052-.558-.204-.885-.36-1.062C10.503 3.1 10.31 3 10 3s-.505.1-.643.256m-1.124-.994C8.689 1.746 9.311 1.5 10 1.5s1.31.246 1.767.762c.331.374.54.85.65 1.383 1.21.369 2.104 1.136 2.686 2.357.604 1.266.859 2.989.894 5.185l1.866 1.874.012.012.011.013c.636.7.806 1.59.372 2.342-.406.705-1.223 1.072-2.103 1.072H12.77c-.128.39-.336.775-.638 1.104-.493.538-1.208.896-2.12.896-.917 0-1.638-.356-2.136-.893A3 3 0 0 1 7.23 16.5H3.843c-.88 0-1.697-.367-2.104-1.072-.433-.752-.263-1.642.373-2.342l.011-.013.012-.012 1.869-1.874c.035-2.196.29-3.919.894-5.185.582-1.22 1.475-1.988 2.684-2.357.112-.533.32-1.009.651-1.383\"},\n {label: \"Files\", d: \"M4.836 3A1.836 1.836 0 0 0 3 4.836v7.328c0 .9.646 1.647 1.5 1.805V7.836A3.336 3.336 0 0 1 7.836 4.5h6.133A1.84 1.84 0 0 0 12.164 3zM1.5 12.164a3.337 3.337 0 0 0 3.015 3.32A3.337 3.337 0 0 0 7.836 18.5h3.968c.73 0 1.43-.29 1.945-.805l3.946-3.946a2.75 2.75 0 0 0 .805-1.945V7.836a3.337 3.337 0 0 0-3.015-3.32A3.337 3.337 0 0 0 12.164 1.5H4.836A3.336 3.336 0 0 0 1.5 4.836zM7.836 6A1.836 1.836 0 0 0 6 7.836v7.328C6 16.178 6.822 17 7.836 17H11.5v-4a1.5 1.5 0 0 1 1.5-1.5h4V7.836A1.836 1.836 0 0 0 15.164 6zm8.486 7H13v3.322z\"},\n {label: \"More\", d: \"M14.5 10a1.75 1.75 0 1 1 3.5 0 1.75 1.75 0 0 1-3.5 0m-6.25 0a1.75 1.75 0 1 1 3.5 0 1.75 1.75 0 0 1-3.5 0M2 10a1.75 1.75 0 1 1 3.5 0A1.75 1.75 0 0 1 2 10\"},\n] as const;\n\nconst THEMES = {\n aubergine: {\n chrome: \"#350D36\",\n sidebar: \"#3F0E40\",\n selected: \"#7D3986\",\n sidebarInk: \"rgba(246,228,255,0.9)\",\n selectedInk: \"#EFE1F5\",\n activeRow: \"#F9EDFF\",\n activeRowInk: \"#39063A\",\n page: \"#FFFFFF\",\n ink: \"#1D1C1D\",\n strong: \"#1D1C1D\",\n muted: \"#616061\",\n edge: \"rgba(29,28,29,0.13)\",\n strip: \"#F8F8F8\",\n toolIcon: \"rgba(29,28,29,0.7)\",\n wash: \"rgba(29,28,29,0.06)\",\n badge: \"#DDDDDD\",\n },\n dark: {\n chrome: \"#101214\",\n sidebar: \"#101214\",\n selected: \"#7D3986\",\n sidebarInk: \"rgba(209,210,211,0.9)\",\n selectedInk: \"#EFE1F5\",\n activeRow: \"#1164A3\",\n activeRowInk: \"#FFFFFF\",\n page: \"#1A1D21\",\n ink: \"#D1D2D3\",\n strong: \"#F8F8F8\",\n muted: \"#ABABAD\",\n edge: \"#35373B\",\n strip: \"#222529\",\n toolIcon: \"rgba(209,210,211,0.8)\",\n wash: \"rgba(248,248,248,0.06)\",\n badge: \"#2C2D30\",\n },\n};\n\nconst LINK = \"#1264A3\";\nconst SEND = \"#007A5A\";\n\n/**\n * Slack sets its interface in Lato, and the weights are the recognition as\n * much as the colours: a username is Lato Black, the channel name beside it is\n * only medium, and everything else is regular. Rendering this in the project's\n * own brand sans is what made an accurate layout still read as not-quite\n * Slack. Helvetica Neue is the fallback because it is the closest thing most\n * machines already have; a project that wants it exact loads Lato in its brand.\n */\nconst FONT = '\"Lato\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\n\n/**\n * A Slack channel where an app answers in the thread.\n *\n * The client is drawn whole because the furniture is the recognition: the\n * window bar, the aubergine rail, one channel lit and unread, and a composer\n * with its formatting row and green send. The composer earns its space: a\n * channel with nowhere to type reads as a screenshot of Slack, and a channel\n * with one reads as somebody's Slack.\n *\n * The exchange keeps three beats and never overlaps them: the question lands,\n * the app shows it is working, the answer types itself into the thread.\n */\nexport const Slack = ({\n workspace = \"Odori\",\n channels = [\"general\", \"launches\", \"support\", {name: \"incidents\", private: true}],\n directMessages = [],\n channel = \"support\",\n ask = {\n author: \"John Doe\",\n mention: \"Odori\",\n body: \"the nightly export came out 40MB heavier than yesterday, same commit\",\n time: \"4:46 PM\",\n },\n reaction = {emoji: \"👀\", count: 2},\n reply = {\n author: \"Odori\",\n app: true,\n body: \"Yesterday reused cached chunks. Today re-encoded at studio quality after the toolchain pin changed.\",\n time: \"< 1 minute ago\",\n color: \"#7C3AED\",\n },\n thinkingFrames = 30,\n theme = \"aubergine\",\n charactersPerSecond = 34,\n}: SlackProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const colors = THEMES[theme];\n const px = (value: number) => value * scale;\n\n /**\n * Slack's type ladder, as ratios of its 15px body: a username is the same\n * size as the message and only heavier, the channel name is a fifth larger,\n * and a timestamp is four fifths. Lines are set at 22 over 15.\n */\n const body = 22;\n const heading = Math.round(body * 1.2);\n const small = Math.round(body * 0.8);\n const LINE = 1.467;\n /**\n * Slack's spacing, also as ratios of its 15px body: a 36px avatar, 28px\n * sidebar rows indented 16px, and message rows padded 8 by 20. The client is\n * tighter than it looks in a screenshot, and getting the type right while\n * leaving the spacing loose is what still read as not-quite-Slack.\n */\n const avatarSize = Math.round(body * 2.4);\n const rowHeight = Math.round(body * 1.867);\n const rowRadius = Math.round(body * 0.4);\n const rowIndent = Math.round(body * 1.067);\n const gutterY = Math.round(body * 0.533);\n const gutterX = Math.round(body * 1.333);\n\n /**\n * The typing belongs to the person, not the app.\n *\n * A person composes in the box and presses send; the app answers with a\n * message that is simply there. Typing the app's reply out character by\n * character is the one thing none of these products actually do, and it is\n * what made the earlier version read as a mock-up.\n */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(ask.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 20;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(ask.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const reacted = spring({frame, fps, delayInFrames: askAt + 16, stiffness: 190, damping: 14});\n const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const working = frame >= workingAt && frame < replyAt;\n\n const avatar = (post: SlackPost, size: number) => (\n <span\n style={{\n alignItems: \"center\",\n /* Slack's own offset: the sender's box starts two pixels above the\n top of the picture, so the picture carries the nudge. */\n marginTop: px(2.9),\n background: post.color ?? DEFAULT_AVATAR,\n borderRadius: px(size / 4.5),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(size * 0.52),\n fontWeight: 500,\n height: px(size),\n justifyContent: \"center\",\n width: px(size),\n }}\n >\n {post.author.slice(0, 1).toUpperCase()}\n </span>\n );\n\n /**\n * One slot in the rail, so a hash, a lock, a presence dot and a section's\n * own icon all land on the same left edge. Slack's rail reads as a column\n * because of this, and ours read as a list of indented strings without it.\n */\n const railGlyph = (node: ReactNode) => (\n <span\n style={{\n alignItems: \"center\",\n display: \"flex\",\n flex: \"none\",\n height: px(body),\n justifyContent: \"center\",\n marginRight: px(9),\n width: px(body * 1.067),\n }}\n >\n {node}\n </span>\n );\n\n /** A section heading, set at the row size rather than a smaller one. */\n const section = (label: string, icon: ReactNode) => (\n <div\n style={{\n alignItems: \"center\",\n color: colors.sidebarInk,\n display: \"flex\",\n fontSize: px(body),\n fontWeight: 500,\n height: px(rowHeight),\n margin: `${px(12)}px ${px(10)}px ${px(2)}px`,\n padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,\n }}\n >\n {railGlyph(icon)}\n {label}\n </div>\n );\n\n /** A composer button: 28 square with an 18 icon, as Slack sizes them. */\n const tool = (name: keyof typeof COMPOSER_ICONS, circle = false) => (\n <span\n key={name}\n style={{\n alignItems: \"center\",\n background: circle ? colors.wash : \"transparent\",\n borderRadius: circle ? px(999) : px(6),\n color: colors.toolIcon,\n display: \"flex\",\n flex: \"none\",\n height: px(41),\n justifyContent: \"center\",\n width: px(41),\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(26)} height={px(26)} fill=\"currentColor\">\n {/* Slack's paths carve their counters with the even-odd rule; without\n it the mention icon fills in as a solid disc. */}\n <path clipRule=\"evenodd\" d={COMPOSER_ICONS[name]} fillRule=\"evenodd\" />\n </svg>\n </span>\n );\n\n const glyph = (node: ReactNode, key: string) => (\n <span key={key} style={{alignItems: \"center\", color: colors.toolIcon, display: \"flex\", height: px(41), justifyContent: \"center\", width: px(41)}}>\n {node}\n </span>\n );\n\n const rule = (key: string) => <span key={key} style={{background: colors.edge, height: px(26), margin: `0 ${px(6)}px`, width: px(1)}} />;\n\n return (\n <Fill style={{background: \"#5B2A5E\", padding: px(64)}}>\n <div\n style={{\n borderRadius: px(12),\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n background: colors.chrome,\n display: \"flex\",\n flex: \"none\",\n gap: px(10),\n padding: `${px(16)}px ${px(20)}px`,\n }}\n >\n {[\"#FF5F57\", \"#FEBC2E\", \"#28C840\"].map((light) => (\n <span key={light} style={{background: light, borderRadius: px(999), height: px(14), width: px(14)}} />\n ))}\n </div>\n\n <div style={{display: \"flex\", flex: 1, minHeight: 0}}>\n {/* The rail: the workspace, then the places, each an icon over its\n own label. The name is not here — it is the column's title. */}\n <div style={{alignItems: \"center\", display: \"flex\", flex: \"none\", flexDirection: \"column\", paddingTop: px(12), width: px(103)}}>\n <span\n style={{\n alignItems: \"center\",\n background: colors.selected,\n borderRadius: px(12),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(24),\n fontWeight: 700,\n height: px(53),\n justifyContent: \"center\",\n marginBottom: px(14),\n width: px(53),\n }}\n >\n {workspace.slice(0, 1).toUpperCase()}\n </span>\n {RAIL.map((place, index) => (\n <div\n key={place.label}\n style={{\n alignItems: \"center\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: px(5),\n height: px(100),\n justifyContent: \"center\",\n width: px(76),\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: index === 0 ? \"rgba(249,237,255,0.25)\" : \"transparent\",\n borderRadius: px(12),\n color: index === 0 ? \"#FFFFFF\" : colors.sidebarInk,\n display: \"flex\",\n height: px(53),\n justifyContent: \"center\",\n width: px(53),\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(29)} height={px(29)} fill=\"currentColor\">\n <path clipRule=\"evenodd\" d={place.d} fillRule=\"evenodd\" />\n </svg>\n </span>\n <span\n style={{\n color: index === 0 ? \"#FFFFFF\" : colors.sidebarInk,\n fontSize: px(16),\n fontWeight: 700,\n lineHeight: 1.1,\n }}\n >\n {place.label}\n </span>\n </div>\n ))}\n </div>\n\n <div style={{background: colors.sidebar, flex: \"none\", paddingTop: px(18), width: px(320)}}>\n {/* The workspace name is the column's title, with the chevron\n Slack sets beside it. */}\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(6), padding: `0 ${px(18)}px ${px(16)}px`}}>\n <span style={{color: colors.selectedInk, fontSize: px(heading), fontWeight: 900}}>{workspace}</span>\n <svg viewBox=\"0 0 20 20\" width={px(20)} height={px(20)} fill=\"none\" stroke={colors.selectedInk} strokeWidth={2.2}>\n <path d=\"M6 8l4 4 4-4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </div>\n\n {section(\n \"Channels\",\n <svg viewBox=\"0 0 20 20\" width={px(body * 1.067)} height={px(body * 1.067)} fill=\"currentColor\">\n <path d=\"M14.5 1.75a3.75 3.75 0 0 1 3.75 3.75v9a3.75 3.75 0 0 1-3.75 3.75h-9a3.75 3.75 0 0 1-3.75-3.75v-9A3.75 3.75 0 0 1 5.5 1.75zm-9 1.5A2.25 2.25 0 0 0 3.25 5.5v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25zm3.152 2.215a.751.751 0 0 1 1.478.256l-.268 1.556h1.365l.313-1.81a.75.75 0 0 1 1.478.256l-.269 1.554h1.658a.751.751 0 0 1 0 1.5h-1.915l-.475 2.753H13.8a.75.75 0 0 1 0 1.5h-2.042l-.26 1.503a.75.75 0 0 1-1.478-.255l.215-1.248H8.869l-.26 1.501a.75.75 0 0 1-1.478-.255l.215-1.246H5.593a.75.75 0 0 1 .001-1.5h2.012l.475-2.753H6.2a.75.75 0 0 1 0-1.5h2.14zm.476 6.065h1.366l.474-2.753H9.603z\" />\n </svg>,\n )}\n {channels.map((entry) => {\n const name = typeof entry === \"string\" ? entry : entry.name;\n const locked = typeof entry === \"string\" ? false : Boolean(entry.private);\n const open = name === channel;\n return (\n <div\n key={name}\n style={{\n alignItems: \"center\",\n background: open ? colors.activeRow : \"transparent\",\n borderRadius: px(rowRadius),\n color: open ? colors.activeRowInk : colors.sidebarInk,\n display: \"flex\",\n fontSize: px(body),\n /* Slack does not bolden the channel you are looking at;\n the pill is what marks it. */\n fontWeight: 400,\n height: px(rowHeight),\n margin: `${px(1)}px ${px(10)}px`,\n padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,\n }}\n >\n {railGlyph(\n locked ? (\n <svg viewBox=\"0 0 24 24\" width={px(body * 0.96)} height={px(body * 0.96)} fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <rect x=\"5\" y=\"10.5\" width=\"14\" height=\"9.5\" rx=\"2.2\" />\n <path d=\"M8.5 10.5V7.8a3.5 3.5 0 0 1 7 0v2.7\" strokeLinecap=\"round\" />\n </svg>\n ) : (\n /* The hash Slack draws, with the strokes raked the way a\n real one is. A typed \"#\" sits on the text baseline and\n reads a size too small beside the name. */\n <svg viewBox=\"0 0 24 24\" width={px(body * 1.02)} height={px(body * 1.02)} fill=\"none\" stroke=\"currentColor\" strokeWidth={2.1} strokeLinecap=\"round\">\n <path d=\"M10 4 8 20M17 4l-2 16M4.6 9.5h15M3.4 14.5h15\" />\n </svg>\n ),\n )}\n {name}\n </div>\n );\n })}\n\n {section(\n \"Direct messages\",\n <svg viewBox=\"0 0 20 20\" width={px(body * 1.067)} height={px(body * 1.067)} fill=\"currentColor\">\n <path clipRule=\"evenodd\" fillRule=\"evenodd\" d=\"M7.675 6.468a4.75 4.75 0 1 1 8.807 3.441.75.75 0 0 0-.067.489l.379 1.896-1.896-.38a.75.75 0 0 0-.489.068 5 5 0 0 1-.648.273.75.75 0 1 0 .478 1.422q.314-.105.611-.242l2.753.55a.75.75 0 0 0 .882-.882l-.55-2.753A6.25 6.25 0 1 0 6.23 6.064a.75.75 0 1 0 1.445.404M6.5 8.5a5 5 0 0 0-4.57 7.03l-.415 2.073a.75.75 0 0 0 .882.882l2.074-.414A5 5 0 1 0 6.5 8.5m-3.5 5a3.5 3.5 0 1 1 1.91 3.119.75.75 0 0 0-.49-.068l-1.214.243.243-1.215a.75.75 0 0 0-.068-.488A3.5 3.5 0 0 1 3 13.5\" />\n </svg>,\n )}\n {directMessages.map((name) => (\n <div\n key={name}\n style={{\n alignItems: \"center\",\n color: colors.sidebarInk,\n display: \"flex\",\n fontSize: px(body),\n height: px(rowHeight),\n margin: `${px(1)}px ${px(10)}px`,\n padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,\n }}\n >\n {railGlyph(<span style={{background: \"#20A271\", borderRadius: px(999), display: \"block\", height: px(9), width: px(9)}} />)}\n {name}\n </div>\n ))}\n\n </div>\n\n {/* Slack floats the conversation as its own rounded panel, with the\n workspace colour showing around it. */}\n <div\n style={{\n background: colors.page,\n borderRadius: px(14),\n display: \"flex\",\n flex: 1,\n flexDirection: \"column\",\n margin: `0 ${px(10)}px ${px(10)}px 0`,\n minWidth: 0,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n alignItems: \"center\",\n color: colors.strong,\n display: \"flex\",\n flex: \"none\",\n fontSize: px(heading),\n fontWeight: 900,\n gap: px(3),\n padding: `${px(gutterY * 1.6)}px ${px(gutterX)}px`,\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(heading)} height={px(heading)} fill=\"currentColor\">\n <g transform=\"translate(10 10) scale(1.55) translate(-10 -10)\">\n <path clipRule=\"evenodd\" d={CHANNEL_HASH} fillRule=\"evenodd\" />\n </g>\n </svg>\n {channel}\n </div>\n\n <div style={{flex: 1, minHeight: 0, padding: `${px(gutterY * 2)}px ${px(gutterX)}px 0`}}>\n <div style={{display: \"flex\", gap: px(gutterY), opacity: asked, transform: `translateY(${(1 - asked) * px(8)}px)`}}>\n {avatar(ask, avatarSize)}\n <div style={{minWidth: 0}}>\n {/* The name's cap sits on the avatar's top edge. A loose\n line-height puts half its leading above the cap, which\n drops the name a few pixels and reads as misaligned. */}\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(10), lineHeight: LINE}}>\n <span style={{color: colors.strong, fontSize: px(body), fontWeight: 900}}>{ask.author}</span>\n <span style={{color: colors.muted, fontSize: px(small)}}>{ask.time}</span>\n </div>\n <div style={{color: colors.ink, fontSize: px(body), lineHeight: LINE, marginTop: px(4)}}>\n {ask.mention ? <span style={{color: LINK}}>@{ask.mention} </span> : null}\n {ask.body}\n </div>\n\n {reaction ? (\n <span\n style={{\n alignItems: \"center\",\n display: \"inline-flex\",\n gap: px(8),\n marginTop: px(12),\n opacity: reacted,\n transform: `scale(${0.8 + reacted * 0.2})`,\n }}\n >\n {/* A reaction you are part of is ringed and tinted in\n Slack's blue; one you are not sits on the 6% wash. */}\n <span\n style={{\n alignItems: \"center\",\n background: reaction.mine === false ? colors.wash : REACTED_WASH,\n borderRadius: px(999),\n boxShadow: reaction.mine === false ? undefined : `0 0 0 ${px(1.5)}px ${REACTED_INK}`,\n display: \"inline-flex\",\n gap: px(6),\n height: px(35),\n padding: `0 ${px(12)}px`,\n }}\n >\n <span style={{fontSize: px(26)}}>{reaction.emoji}</span>\n <span\n style={{\n color: reaction.mine === false ? colors.strong : REACTED_INK,\n fontSize: px(18),\n fontWeight: 700,\n }}\n >\n {reaction.count}\n </span>\n </span>\n <span\n style={{\n alignItems: \"center\",\n background: colors.wash,\n borderRadius: px(999),\n color: colors.strong,\n display: \"inline-flex\",\n height: px(35),\n padding: `0 ${px(12)}px`,\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(26)} height={px(26)} fill=\"currentColor\">\n <path clipRule=\"evenodd\" d={ADD_REACTION} fillRule=\"evenodd\" />\n </svg>\n </span>\n </span>\n ) : null}\n </div>\n </div>\n\n {frame >= workingAt ? (\n <>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(16), margin: `${px(20)}px 0 ${px(16)}px`}}>\n <span style={{color: colors.muted, fontSize: px(small), fontWeight: 700}}>1 reply</span>\n <span style={{background: colors.edge, flex: 1, height: px(1)}} />\n </div>\n\n <div\n style={{\n display: \"flex\",\n gap: px(gutterY),\n opacity: working ? 1 : replied,\n transform: `translateY(${(1 - (working ? 1 : replied)) * px(8)}px)`,\n }}\n >\n {avatar(reply, avatarSize)}\n <div style={{minWidth: 0}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10), lineHeight: LINE}}>\n <span style={{color: colors.strong, fontSize: px(body), fontWeight: 900}}>{reply.author}</span>\n <span\n style={{\n background: colors.badge,\n borderRadius: px(4),\n color: colors.muted,\n fontSize: px(Math.round(body * 0.68)),\n fontWeight: 700,\n padding: `${px(2)}px ${px(8)}px`,\n }}\n >\n APP\n </span>\n {frame >= replyAt ? <span style={{color: colors.muted, fontSize: px(small)}}>{reply.time}</span> : null}\n </div>\n <div style={{marginTop: px(4), minHeight: px(36)}}>\n {working ? (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(8), height: px(32)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: colors.muted,\n borderRadius: px(999),\n height: px(10),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(10),\n }}\n />\n ))}\n </span>\n ) : (\n <span style={{color: colors.ink, fontSize: px(body), lineHeight: LINE}}>{reply.body}</span>\n )}\n </div>\n </div>\n </div>\n </>\n ) : null}\n </div>\n\n <div\n style={{\n background: colors.page,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(12),\n flex: \"none\",\n margin: `${px(20)}px ${px(gutterX)}px ${px(gutterY * 2)}px`,\n overflow: \"hidden\",\n }}\n >\n {/* The formatting strip sits on its own tint, and carries the\n set Slack carries: bold, italic, underline, strike, then\n link and the two lists, then quote, code and code block. */}\n <div\n style={{\n alignItems: \"center\",\n background: colors.strip,\n display: \"flex\",\n gap: px(2),\n padding: px(6),\n }}\n >\n {([\"bold\", \"italic\", \"underline\", \"strike\"] as const).map((name) => tool(name))}\n {rule(\"r1\")}\n {([\"link\", \"ordered\", \"bulleted\"] as const).map((name) => tool(name))}\n {rule(\"r2\")}\n {([\"quote\", \"code\", \"codeblock\"] as const).map((name) => tool(name))}\n </div>\n\n <div\n style={{\n color: sent ? colors.muted : colors.ink,\n fontSize: px(body),\n padding: `${px(18)}px ${px(16)}px ${px(24)}px`,\n }}\n >\n {sent ? (\n `Message #${channel}`\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(22),\n marginLeft: px(2),\n transform: `translateY(${px(4)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(2), padding: `0 ${px(10)}px ${px(10)}px`}}>\n {tool(\"attach\", true)}\n {tool(\"format\")}\n {tool(\"emoji\")}\n {tool(\"mention\")}\n {glyph(\n <svg viewBox=\"0 0 20 20\" width={px(26)} height={px(26)} fill=\"currentColor\">\n <circle cx=\"4\" cy=\"10\" r=\"1.6\" />\n <circle cx=\"10\" cy=\"10\" r=\"1.6\" />\n <circle cx=\"16\" cy=\"10\" r=\"1.6\" />\n </svg>,\n \"more\",\n )}\n\n {/* Send is a plain arrow until there is something to send,\n and only then does it turn green. */}\n <span\n style={{\n alignItems: \"center\",\n background: sent ? \"transparent\" : SEND,\n borderRadius: px(6),\n display: \"inline-flex\",\n marginLeft: \"auto\",\n }}\n >\n <span style={{alignItems: \"center\", display: \"flex\", padding: `${px(8)}px ${px(12)}px`}}>\n <svg viewBox=\"0 0 20 20\" width={px(23)} height={px(23)} fill={sent ? colors.muted : \"#FFFFFF\"}>\n <path clipRule=\"evenodd\" d={COMPOSER_ICONS.send} fillRule=\"evenodd\" />\n </svg>\n </span>\n <span\n style={{\n background: sent ? colors.edge : \"rgba(255,255,255,0.35)\",\n height: px(24),\n width: px(1),\n }}\n />\n <span style={{alignItems: \"center\", display: \"flex\", padding: `${px(8)}px ${px(10)}px`}}>\n <svg viewBox=\"0 0 20 20\" width={px(17)} height={px(17)} fill=\"none\" stroke={sent ? colors.muted : \"#FFFFFF\"} strokeWidth={2}>\n <path d=\"M5 8l5 5 5-5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </span>\n </span>\n </div>\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
3405
+ "target": "videos/components/slack/slack.tsx"
2352
3406
  },
2353
3407
  {
2354
- "path": "components/shared-axis/shared-axis.preview.tsx",
2355
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SharedAxis} from \"./shared-axis\";\n\nconst Panel = () => (\n <div\n style={{\n alignItems: \"center\",\n background: \"#0b0b0b\",\n color: \"#f5f5f5\",\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: \"ui-sans-serif, system-ui\",\n gap: 18,\n inset: 0,\n justifyContent: \"center\",\n position: \"absolute\",\n }}\n >\n <strong style={{fontSize: 84, letterSpacing: \"-0.04em\"}}>Deployment detail</strong>\n <span style={{color: \"#8f8f8f\", fontSize: 32}}>the same thing, one level in</span>\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Shared axis\",\n category: \"Motion\",\n description: \"Related scenes travelling on one spatial axis.\",\n component: SharedAxis,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n axis: {type: \"select\", defaultValue: \"x\", options: [\"x\", \"y\", \"z\"]},\n distance: {type: \"number\", defaultValue: 0.12, min: 0.02, max: 0.4, step: 0.02},\n },\n examples: [\n {name: \"Default\", props: {children: <Panel />}},\n {name: \"Depth\", props: {axis: \"z\", children: <Panel />}},\n ],\n});\n",
2356
- "target": "videos/components/shared-axis/shared-axis.preview.tsx"
3408
+ "path": "components/slack/slack.preview.tsx",
3409
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Slack} from \"./slack\";\n\nexport default defineComponentPreview({\n title: \"Slack\",\n category: \"Products\",\n description: \"A Slack channel where a question is answered by an app in its thread.\",\n component: Slack,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n workspace: {type: \"text\", defaultValue: \"Odori\", maxLength: 24},\n channel: {type: \"text\", defaultValue: \"support\", maxLength: 24},\n thinkingFrames: {type: \"number\", defaultValue: 30, min: 0, max: 90, step: 2},\n theme: {type: \"select\", defaultValue: \"aubergine\", options: [\"aubergine\", \"dark\"]},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Aubergine\", props: {}},\n {name: \"Dark\", props: {theme: \"dark\"}},\n {\n name: \"Incident\",\n props: {\n channel: \"incidents\",\n ask: {\n author: \"Jane Doe\",\n body: \"render workers are timing out on the 4k job\",\n time: \"2:14 AM\",\n color: \"#E01E5A\",\n },\n reaction: {emoji: \"🚨\", count: 3},\n reply: {\n author: \"Odori\",\n app: true,\n body: \"Two workers exceeded the frame budget at 4k. Concurrency is down to 2 and the job is moving again.\",\n time: \"< 1 minute ago\",\n color: \"#7C3AED\",\n },\n },\n },\n ],\n});\n",
3410
+ "target": "videos/components/slack/slack.preview.tsx"
2357
3411
  }
2358
3412
  ],
2359
3413
  "meta": {
2360
3414
  "kind": "component",
2361
- "family": "Motion",
2362
- "namespaced": "@odori/shared-axis",
3415
+ "family": "Products",
3416
+ "namespaced": "@odori/slack",
2363
3417
  "contract": {
2364
3418
  "aspectRatios": [
2365
- "16:9",
2366
- "9:16",
2367
- "1:1"
3419
+ "16:9"
2368
3420
  ],
2369
- "recommendedDurationInFrames": 90,
2370
- "minimumDurationInFrames": 30,
2371
- "entranceFrames": 12,
3421
+ "recommendedDurationInFrames": 270,
3422
+ "minimumDurationInFrames": 150,
3423
+ "entranceFrames": 14,
2372
3424
  "exitFrames": 12,
2373
- "contentLimits": {},
2374
- "reducedMotion": "the scenes cross-fade without moving",
3425
+ "contentLimits": {
3426
+ "workspace": 24,
3427
+ "channel": 24
3428
+ },
3429
+ "reducedMotion": "messages placed, no working indicator",
2375
3430
  "requires": {
2376
- "fonts": [],
3431
+ "fonts": [
3432
+ "sans"
3433
+ ],
2377
3434
  "audio": []
2378
3435
  }
2379
3436
  }
@@ -2391,7 +3448,7 @@
2391
3448
  },
2392
3449
  {
2393
3450
  "path": "components/soft-blur-in/soft-blur-in.preview.tsx",
2394
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SoftBlurIn} from \"./soft-blur-in\";\n\nexport default defineComponentPreview({\n title: \"Soft blur in\",\n category: \"Typography\",\n description: \"A gentle blur and opacity reveal for premium openings.\",\n component: SoftBlurIn,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Built for focus.\", maxLength: 64},\n detail: {type: \"text\", defaultValue: \"Everything you need, nothing you do not.\"},\n blur: {type: \"number\", defaultValue: 26, min: 0, max: 60},\n },\n examples: [\n {name: \"Default\", props: {title: \"Built for focus.\"}},\n {name: \"Subtle\", props: {title: \"Quietly precise.\", blur: 10}},\n ],\n});\n",
3451
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SoftBlurIn} from \"./soft-blur-in\";\n\nexport default defineComponentPreview({\n title: \"Soft blur in\",\n category: \"Typography/Titles\",\n description: \"A gentle blur and opacity reveal for premium openings.\",\n component: SoftBlurIn,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Built for focus.\", maxLength: 64},\n detail: {type: \"text\", defaultValue: \"Everything you need, nothing you do not.\"},\n blur: {type: \"number\", defaultValue: 26, min: 0, max: 60},\n },\n examples: [\n {name: \"Default\", props: {title: \"Built for focus.\"}},\n {name: \"Subtle\", props: {title: \"Quietly precise.\", blur: 10}},\n ],\n});\n",
2395
3452
  "target": "videos/components/soft-blur-in/soft-blur-in.preview.tsx"
2396
3453
  }
2397
3454
  ],
@@ -2518,13 +3575,13 @@
2518
3575
  },
2519
3576
  {
2520
3577
  "path": "components/stage/stage.preview.tsx",
2521
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Stage} from \"./stage\";\n\nexport default defineComponentPreview({\n title: \"Stage\",\n category: \"Brand\",\n description: \"The shared backdrop: hairline grid, soft glow, and vignette.\",\n component: Stage,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n grid: {type: \"boolean\", defaultValue: true},\n glow: {type: \"boolean\", defaultValue: true},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Flat\", props: {grid: false, glow: false}},\n ],\n});\n",
3578
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Stage} from \"./stage\";\n\nexport default defineComponentPreview({\n title: \"Stage\",\n category: \"Foundation\",\n description: \"The shared backdrop: hairline grid, soft glow, and vignette.\",\n component: Stage,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n grid: {type: \"boolean\", defaultValue: true},\n glow: {type: \"boolean\", defaultValue: true},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Flat\", props: {grid: false, glow: false}},\n ],\n});\n",
2522
3579
  "target": "videos/components/stage/stage.preview.tsx"
2523
3580
  }
2524
3581
  ],
2525
3582
  "meta": {
2526
3583
  "kind": "component",
2527
- "family": "Brand",
3584
+ "family": "Foundation",
2528
3585
  "namespaced": "@odori/stage",
2529
3586
  "contract": {
2530
3587
  "aspectRatios": [
@@ -2557,7 +3614,7 @@
2557
3614
  },
2558
3615
  {
2559
3616
  "path": "components/staggered-lines/staggered-lines.preview.tsx",
2560
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {StaggeredLines} from \"./staggered-lines\";\n\nexport default defineComponentPreview({\n title: \"Staggered lines\",\n category: \"Typography\",\n description: \"Two line hierarchy with independent entrance timing.\",\n component: StaggeredLines,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n align: {type: \"select\", defaultValue: \"left\", options: [\"left\", \"center\"]},\n stagger: {type: \"number\", defaultValue: 10, min: 4, max: 30},\n detail: {type: \"text\", defaultValue: \"From first scene to final render.\"},\n },\n examples: [\n {name: \"Default\", props: {lines: [\"One workflow.\", \"Every launch.\"]}},\n {name: \"Centered\", props: {lines: [\"Author the story.\", \"Render the proof.\"], align: \"center\"}},\n ],\n});\n",
3617
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {StaggeredLines} from \"./staggered-lines\";\n\nexport default defineComponentPreview({\n title: \"Staggered lines\",\n category: \"Typography/Titles\",\n description: \"Two line hierarchy with independent entrance timing.\",\n component: StaggeredLines,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n align: {type: \"select\", defaultValue: \"left\", options: [\"left\", \"center\"]},\n stagger: {type: \"number\", defaultValue: 10, min: 4, max: 30},\n detail: {type: \"text\", defaultValue: \"From first scene to final render.\"},\n },\n examples: [\n {name: \"Default\", props: {lines: [\"One workflow.\", \"Every launch.\"]}},\n {name: \"Centered\", props: {lines: [\"Author the story.\", \"Render the proof.\"], align: \"center\"}},\n ],\n});\n",
2561
3618
  "target": "videos/components/staggered-lines/staggered-lines.preview.tsx"
2562
3619
  }
2563
3620
  ],
@@ -2634,6 +3691,51 @@
2634
3691
  }
2635
3692
  }
2636
3693
  },
3694
+ {
3695
+ "name": "statement",
3696
+ "description": "A sentence under the reveal discipline you choose, with product controls set inline.",
3697
+ "registryDependencies": [],
3698
+ "files": [
3699
+ {
3700
+ "path": "components/statement/statement.tsx",
3701
+ "content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type StatementChip = {\n /** The chip's text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: \"neutral\" | \"dark\" | \"accent\";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: \"characters\" | \"words\" | \"none\";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: \"rise\" | \"cut\";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: \"left\" | \"center\";\n /** Light page or dark page. Chips and text follow it. */\n theme?: \"light\" | \"dark\";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== \"string\";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = \"words\",\n motion = \"rise\",\n stagger,\n align = \"left\",\n theme = \"light\",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === \"dark\";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === \"characters\" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === \"characters\" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === \"none\") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === \"cut\") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === \"cut\"\n ? {display: \"inline-block\", opacity: progress}\n : {\n display: \"inline-block\",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip[\"tone\"]) => {\n if (tone === \"accent\") return {background: brand.colors.accent, color: paper, border: \"transparent\"};\n if (tone === \"dark\") return {background: ink, color: paper, border: \"transparent\"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: \"center\",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: \"inline-flex\",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: \"nowrap\",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === \"center\" ? \"center\" : \"flex-start\",\n background: dark ? ink : \"transparent\",\n justifyContent: \"center\",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n color: ink,\n columnGap: 16 * scale,\n display: \"flex\",\n flexWrap: \"wrap\",\n fontSize: 64 * scale,\n fontWeight: 500,\n justifyContent: align === \"center\" ? \"center\" : \"flex-start\",\n letterSpacing: \"-0.03em\",\n lineHeight: 1.35,\n rowGap: 8 * scale,\n textAlign: align,\n }}\n >\n {parts.map((part, index) => {\n const swapping = swap !== undefined && index === lastChip;\n const swapped = swapping\n ? interpolate(frame, [swapFrame, swapFrame + (motion === \"cut\" ? 1 : 16)], [0, 1], {\n easing: Easing.standard,\n })\n : 0;\n\n if (!isChip(part)) {\n // In a pre-measured layout every glyph already occupies its final\n // place, so revealing one cannot move the ones after it.\n if (reveal === \"characters\") {\n return (\n <span key={`${part}-${index}`} style={{display: \"inline-block\", whiteSpace: \"pre\"}}>\n {[...part].map((character, position) => (\n <span key={position} style={{display: \"inline-block\", opacity: shown(offsets[index] + position)}}>\n {character}\n </span>\n ))}\n </span>\n );\n }\n return (\n <span key={`${part}-${index}`} style={enter(shown(offsets[index]))}>\n {part}\n </span>\n );\n }\n\n const entrance = shown(offsets[index]);\n return (\n <span\n key={`chip-${index}`}\n style={{\n ...enter(entrance),\n // A chip settles from slightly small, the way a control that\n // just appeared under the cursor does. A cut skips the settle.\n ...(motion === \"cut\"\n ? {}\n : {transform: `translateY(${(1 - entrance) * 26 * scale}px) scale(${0.92 + entrance * 0.08})`}),\n }}\n >\n {swapping ? (\n <span style={{display: \"inline-grid\"}}>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: 1 - swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${swapped * -18 * scale}px)`,\n }}\n >\n {chipBody(part)}\n </span>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${(1 - swapped) * 18 * scale}px)`,\n }}\n >\n {chipBody(swap)}\n </span>\n </span>\n ) : (\n chipBody(part)\n )}\n </span>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
3702
+ "target": "videos/components/statement/statement.tsx"
3703
+ },
3704
+ {
3705
+ "path": "components/statement/statement.preview.tsx",
3706
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Statement} from \"./statement\";\n\nexport default defineComponentPreview({\n title: \"Statement\",\n category: \"Typography/Titles\",\n description: \"A sentence under the reveal discipline you choose, with product controls set inline.\",\n component: Statement,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n reveal: {type: \"select\", options: [\"characters\", \"words\", \"none\"], defaultValue: \"words\"},\n motion: {type: \"select\", options: [\"rise\", \"cut\"], defaultValue: \"rise\"},\n align: {type: \"select\", options: [\"left\", \"center\"], defaultValue: \"left\"},\n theme: {type: \"select\", options: [\"light\", \"dark\"], defaultValue: \"light\"},\n },\n examples: [\n {\n name: \"Naming a control\",\n props: {parts: [\"Create a repo and start a\", {label: \"New\", icon: \"+\"}, \"project\"]},\n },\n {\n name: \"Typed, no caret\",\n props: {parts: [\"Ask anything. Get an answer.\"], reveal: \"characters\", align: \"center\"},\n },\n {\n name: \"Hard cut, no easing\",\n props: {parts: [\"One\", \"command.\", \"Any\", \"provider.\"], motion: \"cut\", stagger: 5, align: \"center\"},\n },\n {\n name: \"Swapping vendors\",\n props: {\n parts: [\"Run CI from\", {label: \"Buildkite\", icon: \"▲\"}],\n swap: {label: \"Depot\", icon: \"▦\"},\n swapAt: 40,\n theme: \"dark\",\n },\n },\n ],\n});\n",
3707
+ "target": "videos/components/statement/statement.preview.tsx"
3708
+ }
3709
+ ],
3710
+ "meta": {
3711
+ "kind": "component",
3712
+ "family": "Typography",
3713
+ "namespaced": "@odori/statement",
3714
+ "contract": {
3715
+ "aspectRatios": [
3716
+ "16:9",
3717
+ "9:16",
3718
+ "1:1"
3719
+ ],
3720
+ "recommendedDurationInFrames": 120,
3721
+ "minimumDurationInFrames": 54,
3722
+ "entranceFrames": 30,
3723
+ "exitFrames": 10,
3724
+ "contentLimits": {
3725
+ "parts": 9,
3726
+ "partLength": 44,
3727
+ "chipLabel": 14
3728
+ },
3729
+ "reducedMotion": "every part appears together, and a swap cuts",
3730
+ "requires": {
3731
+ "fonts": [
3732
+ "sans"
3733
+ ],
3734
+ "audio": []
3735
+ }
3736
+ }
3737
+ }
3738
+ },
2637
3739
  {
2638
3740
  "name": "sting-close",
2639
3741
  "description": "A resolving fall under an end card.",
@@ -2646,7 +3748,7 @@
2646
3748
  },
2647
3749
  {
2648
3750
  "path": "components/sting-close/sting-close.preview.tsx",
2649
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {stingClose, type StingCloseOptions} from \"./sting-close\";\n\nconst Wave = ({root, peak}: StingCloseOptions) => <CueWave cue={stingClose({root, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Closing sting\",\n category: \"Sound\",\n description: \"A resolving fall under an end card.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n root: {type: \"select\", defaultValue: \"d3\", options: [\"c3\", \"d3\", \"f3\", \"g3\"]},\n peak: {type: \"number\", defaultValue: 0.8, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Lower\", props: {root: \"c3\"}}],\n});\n",
3751
+ "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {stingClose, type StingCloseOptions} from \"./sting-close\";\n\nconst Wave = ({root, peak}: StingCloseOptions) => <CueWave cue={stingClose({root, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Closing sting\",\n category: \"Sound/Stings\",\n description: \"A resolving fall under an end card.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n root: {type: \"select\", defaultValue: \"d3\", options: [\"c3\", \"d3\", \"f3\", \"g3\"]},\n peak: {type: \"number\", defaultValue: 0.8, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Lower\", props: {root: \"c3\"}}],\n});\n",
2650
3752
  "target": "videos/components/sting-close/sting-close.preview.tsx"
2651
3753
  }
2652
3754
  ],
@@ -2692,7 +3794,7 @@
2692
3794
  },
2693
3795
  {
2694
3796
  "path": "components/sting-open/sting-open.preview.tsx",
2695
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {stingOpen, type StingOpenOptions} from \"./sting-open\";\n\nconst Wave = ({root, peak}: StingOpenOptions) => <CueWave cue={stingOpen({root, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Opening sting\",\n category: \"Sound\",\n description: \"A breath of air and an arriving chord, for a title.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n root: {type: \"select\", defaultValue: \"a3\", options: [\"f3\", \"g3\", \"a3\", \"c4\"]},\n peak: {type: \"number\", defaultValue: 0.85, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Higher\", props: {root: \"c4\"}}],\n});\n",
3797
+ "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {stingOpen, type StingOpenOptions} from \"./sting-open\";\n\nconst Wave = ({root, peak}: StingOpenOptions) => <CueWave cue={stingOpen({root, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Opening sting\",\n category: \"Sound/Stings\",\n description: \"A breath of air and an arriving chord, for a title.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n root: {type: \"select\", defaultValue: \"a3\", options: [\"f3\", \"g3\", \"a3\", \"c4\"]},\n peak: {type: \"number\", defaultValue: 0.85, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Higher\", props: {root: \"c4\"}}],\n});\n",
2696
3798
  "target": "videos/components/sting-open/sting-open.preview.tsx"
2697
3799
  }
2698
3800
  ],
@@ -2738,7 +3840,7 @@
2738
3840
  },
2739
3841
  {
2740
3842
  "path": "components/success/success.preview.tsx",
2741
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {success, type SuccessOptions} from \"./success\";\n\nconst Wave = ({root, peak}: SuccessOptions) => <CueWave cue={success({root, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Success\",\n category: \"Sound\",\n description: \"Two notes rising a fifth, for a confirmation.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n root: {type: \"select\", defaultValue: \"e5\", options: [\"c5\", \"d5\", \"e5\", \"g5\"]},\n peak: {type: \"number\", defaultValue: 0.6, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Lower\", props: {root: \"c5\"}}],\n});\n",
3843
+ "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {success, type SuccessOptions} from \"./success\";\n\nconst Wave = ({root, peak}: SuccessOptions) => <CueWave cue={success({root, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Success\",\n category: \"Sound/Interface\",\n description: \"Two notes rising a fifth, for a confirmation.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n root: {type: \"select\", defaultValue: \"e5\", options: [\"c5\", \"d5\", \"e5\", \"g5\"]},\n peak: {type: \"number\", defaultValue: 0.6, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Lower\", props: {root: \"c5\"}}],\n});\n",
2742
3844
  "target": "videos/components/success/success.preview.tsx"
2743
3845
  }
2744
3846
  ],
@@ -2784,13 +3886,13 @@
2784
3886
  },
2785
3887
  {
2786
3888
  "path": "components/table-focus/table-focus.preview.tsx",
2787
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {TableFocus} from \"./table-focus\";\n\nconst ROWS = [\n [\"odori.dev\", \"production\", \"48s\", \"ready\"],\n [\"docs-site\", \"preview\", \"31s\", \"ready\"],\n [\"studio\", \"production\", \"62s\", \"building\"],\n [\"registry\", \"preview\", \"12s\", \"ready\"],\n];\n\nexport default defineComponentPreview({\n title: \"Table focus\",\n category: \"Product UI\",\n description: \"Rows and columns with one animated focal selection.\",\n component: TableFocus,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n focus: {type: \"number\", defaultValue: 1, min: 0, max: 3},\n focusAt: {type: \"number\", defaultValue: 30, min: 0, max: 90},\n dim: {type: \"boolean\", defaultValue: true},\n },\n examples: [\n {name: \"Default\", props: {rows: ROWS}},\n {name: \"Last row\", props: {rows: ROWS, focus: 3}},\n {name: \"No dimming\", props: {rows: ROWS, dim: false}},\n ],\n});\n",
3889
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {TableFocus} from \"./table-focus\";\n\nconst ROWS = [\n [\"odori.dev\", \"production\", \"48s\", \"ready\"],\n [\"docs-site\", \"preview\", \"31s\", \"ready\"],\n [\"studio\", \"production\", \"62s\", \"building\"],\n [\"registry\", \"preview\", \"12s\", \"ready\"],\n];\n\nexport default defineComponentPreview({\n title: \"Table focus\",\n category: \"Interface/Surfaces\",\n description: \"Rows and columns with one animated focal selection.\",\n component: TableFocus,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n focus: {type: \"number\", defaultValue: 1, min: 0, max: 3},\n focusAt: {type: \"number\", defaultValue: 30, min: 0, max: 90},\n dim: {type: \"boolean\", defaultValue: true},\n },\n examples: [\n {name: \"Default\", props: {rows: ROWS}},\n {name: \"Last row\", props: {rows: ROWS, focus: 3}},\n {name: \"No dimming\", props: {rows: ROWS, dim: false}},\n ],\n});\n",
2788
3890
  "target": "videos/components/table-focus/table-focus.preview.tsx"
2789
3891
  }
2790
3892
  ],
2791
3893
  "meta": {
2792
3894
  "kind": "component",
2793
- "family": "Product UI",
3895
+ "family": "Interface",
2794
3896
  "namespaced": "@odori/table-focus",
2795
3897
  "contract": {
2796
3898
  "aspectRatios": [
@@ -2816,6 +3918,130 @@
2816
3918
  }
2817
3919
  }
2818
3920
  },
3921
+ {
3922
+ "name": "tabs-switch",
3923
+ "description": "Tabs switching with the indicator travelling between them.",
3924
+ "registryDependencies": [],
3925
+ "files": [
3926
+ {
3927
+ "path": "components/tabs-switch/tabs-switch.tsx",
3928
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type TabsSwitchProps = {\n /** The tabs across the top. */\n tabs?: string[];\n /** Which tab is selected, in order, one per beat. */\n order?: number[];\n /** Frames each tab is held before the next. */\n holdFrames?: number;\n /** Lines of content under each tab, keyed by tab index. */\n panels?: string[][];\n};\n\n/**\n * Tabs switching, with the indicator travelling rather than jumping.\n *\n * The indicator is the component. A tab set where the underline cuts from one\n * position to the next reads as two screenshots; one where it slides reads as\n * a control being used, and that is the entire difference between showing a\n * product and showing someone using a product.\n */\nexport const TabsSwitch = ({\n tabs = [\"Overview\", \"Deployments\", \"Analytics\", \"Settings\"],\n order = [0, 1, 2],\n holdFrames = 50,\n panels = [\n [\"Production\", \"Ready · 2h ago\", \"odori.dev\"],\n [\"12 deployments this week\", \"Latest: 4m 12s\", \"All checks passed\"],\n [\"48.2k requests\", \"p95 118ms\", \"0 errors\"],\n [\"Team access\", \"Environment variables\", \"Domains\"],\n ],\n}: TabsSwitchProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n // A continuous position through the order, so the indicator is always\n // between two real tabs rather than snapping to one.\n const step = Math.max(1, holdFrames);\n const at = Math.max(0, (frame - 20) / step);\n const index = Math.min(order.length - 1, Math.floor(at));\n const next = Math.min(order.length - 1, index + 1);\n const travel = interpolate(at - index, [0.55, 1], [0, 1], {easing: Easing.standard});\n const position = order[index] + (order[next] - order[index]) * travel;\n const active = travel > 0.5 ? order[next] : order[index];\n const width = 100 / tabs.length;\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#09090B\", justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n background: \"#0C0C0E\",\n border: `${px(1)}px solid #1F1F23`,\n borderRadius: px(16),\n fontFamily: brand.typography.sans,\n maxWidth: px(1180),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(14)}px)`,\n width: \"100%\",\n }}\n >\n <div style={{borderBottom: `${px(1)}px solid #1F1F23`, position: \"relative\"}}>\n <div style={{display: \"flex\"}}>\n {tabs.map((tab, tabIndex) => (\n <span\n key={tab}\n style={{\n color: tabIndex === active ? \"#FAFAFA\" : \"#8A8A93\",\n fontSize: px(24),\n fontWeight: tabIndex === active ? 500 : 400,\n padding: `${px(22)}px 0`,\n textAlign: \"center\",\n width: `${width}%`,\n }}\n >\n {tab}\n </span>\n ))}\n </div>\n <span\n style={{\n background: \"#FAFAFA\",\n bottom: 0,\n height: px(2),\n left: `${position * width}%`,\n position: \"absolute\",\n width: `${width}%`,\n }}\n />\n </div>\n\n <div style={{display: \"grid\", gap: px(16), padding: `${px(34)}px ${px(34)}px ${px(40)}px`}}>\n {(panels[active] ?? []).map((line, lineIndex) => (\n <div\n key={line}\n style={{\n color: lineIndex === 0 ? \"#FAFAFA\" : \"#8A8A93\",\n fontSize: lineIndex === 0 ? px(30) : px(22),\n fontWeight: lineIndex === 0 ? 500 : 400,\n // Content fades in behind the indicator rather than with it, so\n // the eye follows one thing and then reads the other.\n opacity: interpolate(Math.abs(at - Math.round(at)), [0.5, 0.25], [0, 1], {easing: Easing.standard}),\n }}\n >\n {line}\n </div>\n ))}\n </div>\n </div>\n </Fill>\n );\n};\n",
3929
+ "target": "videos/components/tabs-switch/tabs-switch.tsx"
3930
+ },
3931
+ {
3932
+ "path": "components/tabs-switch/tabs-switch.preview.tsx",
3933
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {TabsSwitch} from \"./tabs-switch\";\n\nexport default defineComponentPreview({\n title: \"Tabs\",\n category: \"Interface/Controls\",\n description: \"Tabs switching with the indicator travelling between them.\",\n component: TabsSwitch,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {holdFrames: {type: \"number\", defaultValue: 50, min: 20, max: 120, step: 5}},\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Two tabs\", props: {tabs: [\"Preview\", \"Code\"], order: [0, 1], panels: [[\"The component, playing\"], [\"The source you install\"]]}},\n ],\n});\n",
3934
+ "target": "videos/components/tabs-switch/tabs-switch.preview.tsx"
3935
+ }
3936
+ ],
3937
+ "meta": {
3938
+ "kind": "component",
3939
+ "family": "Interface",
3940
+ "namespaced": "@odori/tabs-switch",
3941
+ "contract": {
3942
+ "aspectRatios": [
3943
+ "16:9",
3944
+ "1:1"
3945
+ ],
3946
+ "recommendedDurationInFrames": 240,
3947
+ "minimumDurationInFrames": 90,
3948
+ "entranceFrames": 14,
3949
+ "exitFrames": 10,
3950
+ "contentLimits": {},
3951
+ "reducedMotion": "final tab shown, indicator placed",
3952
+ "requires": {
3953
+ "fonts": [
3954
+ "sans"
3955
+ ],
3956
+ "audio": []
3957
+ }
3958
+ }
3959
+ }
3960
+ },
3961
+ {
3962
+ "name": "teams",
3963
+ "description": "A Teams channel where an app answers a post with a card.",
3964
+ "registryDependencies": [],
3965
+ "files": [
3966
+ {
3967
+ "path": "components/teams/teams.tsx",
3968
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\n\nexport type TeamsProps = {\n /** The team the channel belongs to. */\n team?: string;\n /** Channels under the team. */\n channels?: string[];\n /** The channel on screen. */\n channel?: string;\n /** The post that starts the thread. */\n ask?: {author: string; body: string; time?: string};\n /** The app's reply. */\n agent?: {name: string; body: string; time?: string};\n /** The card under the reply, which is how apps answer in Teams. */\n card?: {title: string; body?: string; action?: string};\n thinkingFrames?: number;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\nconst PALETTE = {\n dark: {rail: \"#0A0A0A\", side: \"#0F0F0F\", page: \"#141414\", card: \"#1F1F1F\", edge: \"#2E2E2E\", ink: \"#FFFFFF\", faint: \"#ADADAD\"},\n light: {rail: \"#EBEBEB\", side: \"#F5F5F5\", page: \"#FFFFFF\", card: \"#FAFAFA\", edge: \"#E1E1E1\", ink: \"#242424\", faint: \"#616161\"},\n};\nconst ACCENT = \"#5B5FC7\";\n/** Teams sets its interface in the system face, Segoe UI where present. */\nconst FONT = '-apple-system, system-ui, \"Segoe UI\", \"Segoe UI Web\", Helvetica, Arial, sans-serif';\n\n/**\n * A Teams channel with an app answering a post.\n *\n * Teams is card shaped rather than line shaped: a reply of consequence\n * arrives as a bordered block with a title and an action, not as a sentence\n * in a stream. The app rail down the far left and the tab strip over the\n * channel are the two pieces of furniture that say Teams before a word is\n * read, which is why they are drawn even though nothing in the shot uses them.\n */\nexport const Teams = ({\n team = \"Engineering\",\n channels = [\"General\", \"Releases\", \"Incidents\", \"Design\"],\n channel = \"Releases\",\n ask = {author: \"John Doe\", body: \"Can we get the launch cut re-rendered at web quality?\", time: \"9:38 AM\"},\n agent = {name: \"Odori\", body: \"Re-rendered and posted. Half the size, same frames.\", time: \"9:41 AM\"},\n card = {title: \"launch.mp4 · 12.4 MB\", body: \"1920x1080 · 30fps · web quality\", action: \"Open in Studio\"},\n thinkingFrames = 30,\n theme = \"dark\",\n charactersPerSecond = 34,\n}: TeamsProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n /* The typing is the person's: they compose the post, and the app answers\n with a message that is simply there when it arrives. */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(ask.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 20;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(ask.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const landed = frame >= replyAt;\n const working = frame >= workingAt && frame < replyAt;\n const cardIn = interpolate(landed ? frame : 0, [0, 12], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{background: \"#000000\", padding: px(60)}}>\n <div\n style={{\n borderRadius: px(14),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The app rail. */}\n <div\n style={{\n alignItems: \"center\",\n background: colors.rail,\n display: \"flex\",\n flex: \"none\",\n flexDirection: \"column\",\n gap: px(26),\n padding: `${px(22)}px 0`,\n width: px(88),\n }}\n >\n {[\"Activity\", \"Chat\", \"Teams\", \"Calendar\"].map((label, index) => (\n <div key={label} style={{alignItems: \"center\", display: \"flex\", flexDirection: \"column\", gap: px(5)}}>\n <span\n style={{\n background: index === 2 ? ACCENT : \"transparent\",\n border: `${px(2)}px solid ${index === 2 ? ACCENT : colors.faint}`,\n borderRadius: px(7),\n height: px(26),\n width: px(26),\n }}\n />\n <span style={{color: index === 2 ? colors.ink : colors.faint, fontSize: px(13)}}>{label}</span>\n </div>\n ))}\n </div>\n\n {/* The teams and channels list. */}\n <div style={{background: colors.side, borderRight: `${px(1)}px solid ${colors.edge}`, flex: \"none\", width: px(300)}}>\n <div style={{color: colors.ink, fontSize: px(24), fontWeight: 600, padding: `${px(22)}px ${px(20)}px ${px(16)}px`}}>\n Teams\n </div>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12), padding: `${px(8)}px ${px(20)}px`}}>\n <span style={{background: \"#8A6ED1\", borderRadius: px(8), height: px(36), width: px(36)}} />\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{team}</span>\n </div>\n {channels.map((name) => (\n <div\n key={name}\n style={{\n borderLeft: `${px(3)}px solid ${name === channel ? ACCENT : \"transparent\"}`,\n color: name === channel ? colors.ink : colors.faint,\n fontSize: px(19),\n fontWeight: name === channel ? 600 : 400,\n padding: `${px(9)}px ${px(20)}px ${px(9)}px ${px(48)}px`,\n }}\n >\n {name}\n </div>\n ))}\n </div>\n\n {/* The channel. */}\n <div style={{background: colors.page, display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div style={{borderBottom: `${px(1)}px solid ${colors.edge}`, padding: `${px(18)}px ${px(26)}px 0`}}>\n <div style={{color: colors.ink, fontSize: px(25), fontWeight: 600}}>{channel}</div>\n <div style={{display: \"flex\", gap: px(24), marginTop: px(14)}}>\n {[\"Posts\", \"Files\", \"Notes\"].map((tab, index) => (\n <span\n key={tab}\n style={{\n borderBottom: `${px(2)}px solid ${index === 0 ? ACCENT : \"transparent\"}`,\n color: index === 0 ? colors.ink : colors.faint,\n fontSize: px(19),\n paddingBottom: px(10),\n }}\n >\n {tab}\n </span>\n ))}\n </div>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", justifyContent: \"flex-end\", padding: `${px(20)}px ${px(26)}px`}}>\n <div\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(8),\n opacity: asked,\n padding: `${px(18)}px ${px(20)}px`,\n transform: `translateY(${(1 - asked) * px(8)}px)`,\n }}\n >\n {/* The avatar sits beside the name, and everything the person said\n lines up with the name rather than starting under the avatar. */}\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12)}}>\n <span\n style={{\n alignItems: \"center\",\n background: \"#8A6ED1\",\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(19),\n height: px(42),\n justifyContent: \"center\",\n width: px(42),\n }}\n >\n {ask.author.slice(0, 1).toUpperCase()}\n </span>\n <span style={{color: colors.ink, fontSize: px(21), fontWeight: 600}}>{ask.author}</span>\n <span style={{color: colors.faint, fontSize: px(17)}}>{ask.time}</span>\n </div>\n <div\n style={{\n color: colors.ink,\n fontSize: px(21),\n lineHeight: 1.5,\n marginTop: px(10),\n paddingLeft: px(42 + 12),\n }}\n >\n {ask.body}\n </div>\n\n <div\n style={{\n borderLeft: `${px(3)}px solid ${ACCENT}`,\n marginTop: px(18),\n opacity: working ? 1 : replied,\n paddingLeft: px(16),\n }}\n >\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10)}}>\n <span style={{background: ACCENT, borderRadius: px(7), height: px(34), width: px(34)}} />\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{agent.name}</span>\n <span\n style={{\n background: ACCENT,\n borderRadius: px(4),\n color: \"#FFFFFF\",\n fontSize: px(13),\n fontWeight: 600,\n padding: `${px(1)}px ${px(7)}px`,\n }}\n >\n APP\n </span>\n {landed ? <span style={{color: colors.faint, fontSize: px(17)}}>{agent.time}</span> : null}\n </div>\n <div style={{marginTop: px(8), minHeight: px(34), paddingLeft: px(34 + 10)}}>\n {working ? (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(7), height: px(28)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: colors.faint,\n borderRadius: px(999),\n height: px(9),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(9),\n }}\n />\n ))}\n </span>\n ) : (\n <span style={{color: colors.ink, fontSize: px(21), lineHeight: 1.5}}>{agent.body}</span>\n )}\n </div>\n\n {card && landed ? (\n <div\n style={{\n background: colors.card,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(8),\n marginLeft: px(34 + 10),\n marginTop: px(14),\n opacity: cardIn,\n padding: `${px(16)}px ${px(18)}px`,\n transform: `translateY(${(1 - cardIn) * px(8)}px)`,\n }}\n >\n <div style={{color: colors.ink, fontSize: px(21), fontWeight: 600}}>{card.title}</div>\n {card.body ? (\n <div style={{color: colors.faint, fontSize: px(18), marginTop: px(5)}}>{card.body}</div>\n ) : null}\n {card.action ? (\n <div\n style={{\n border: `${px(1)}px solid ${ACCENT}`,\n borderRadius: px(6),\n color: ACCENT,\n display: \"inline-block\",\n fontSize: px(18),\n fontWeight: 600,\n marginTop: px(12),\n padding: `${px(8)}px ${px(16)}px`,\n }}\n >\n {card.action}\n </div>\n ) : null}\n </div>\n ) : null}\n </div>\n </div>\n\n <div\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(8),\n color: sent ? colors.faint : colors.ink,\n fontSize: px(19),\n marginTop: px(18),\n padding: `${px(14)}px ${px(18)}px`,\n }}\n >\n {sent ? (\n \"Reply\"\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(20),\n marginLeft: px(2),\n transform: `translateY(${px(3)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
3969
+ "target": "videos/components/teams/teams.tsx"
3970
+ },
3971
+ {
3972
+ "path": "components/teams/teams.preview.tsx",
3973
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Teams} from \"./teams\";\n\nexport default defineComponentPreview({\n title: \"Microsoft Teams\",\n category: \"Products\",\n description: \"A Teams channel where an app answers a post with a card.\",\n component: Teams,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n team: {type: \"text\", defaultValue: \"Engineering\", maxLength: 24},\n channel: {type: \"text\", defaultValue: \"Releases\", maxLength: 24},\n thinkingFrames: {type: \"number\", defaultValue: 30, min: 0, max: 90, step: 2},\n theme: {type: \"select\", defaultValue: \"dark\", options: [\"dark\", \"light\"]},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Light\", props: {theme: \"light\"}},\n {name: \"No card\", props: {card: undefined}},\n ],\n});\n",
3974
+ "target": "videos/components/teams/teams.preview.tsx"
3975
+ }
3976
+ ],
3977
+ "meta": {
3978
+ "kind": "component",
3979
+ "family": "Products",
3980
+ "namespaced": "@odori/teams",
3981
+ "contract": {
3982
+ "aspectRatios": [
3983
+ "16:9"
3984
+ ],
3985
+ "recommendedDurationInFrames": 270,
3986
+ "minimumDurationInFrames": 150,
3987
+ "entranceFrames": 14,
3988
+ "exitFrames": 12,
3989
+ "contentLimits": {
3990
+ "team": 24,
3991
+ "channel": 24
3992
+ },
3993
+ "reducedMotion": "post and card placed, no working indicator",
3994
+ "requires": {
3995
+ "fonts": [
3996
+ "sans"
3997
+ ],
3998
+ "audio": []
3999
+ }
4000
+ }
4001
+ }
4002
+ },
4003
+ {
4004
+ "name": "telegram",
4005
+ "description": "A Telegram client with the chat list beside a bot answering in the open chat.",
4006
+ "registryDependencies": [],
4007
+ "files": [
4008
+ {
4009
+ "path": "components/telegram/telegram.tsx",
4010
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type TelegramBubble = {from: \"them\" | \"you\"; body: string; time?: string};\n\nexport type TelegramChat = {name: string; preview: string; time?: string; unread?: number};\n\nexport type TelegramProps = {\n /** The chat list down the left. The first is the open conversation. */\n chats?: TelegramChat[];\n /** The chat title in the bar. */\n title?: string;\n /** What sits under the title: a member count, or \"bot\". */\n status?: string;\n /** Bubbles already in the chat. */\n bubbles?: TelegramBubble[];\n /** The reply, typed in after the header says it is typing. */\n reply?: TelegramBubble;\n thinkingFrames?: number;\n charactersPerSecond?: number;\n};\n\n/**\n * Telegram's night theme, read from the web client's own theme map: the\n * accent is a violet rather than the blue the day theme uses, and the\n * surface a chat list sits on is a step lighter than the chat behind it.\n */\nconst RAIL = \"#212121\";\nconst PAGE = \"#181818\";\nconst THEM = \"#212121\";\nconst YOU = \"#8774E1\";\nconst INK = \"#FFFFFF\";\nconst FAINT = \"#AAAAAA\";\nconst ACCENT = \"#8774E1\";\nconst EDGE = \"#0F0F0F\";\nconst FIELD = \"#181818\";\n/** Telegram sets its interface in Roboto. */\nconst FONT = '\"Roboto\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", sans-serif';\n/** The client's own message metrics: 16px on 1.3125, timestamps four down. */\nconst LINE = 1.3125;\n\n/** The seven gradients Telegram assigns an avatar that has no photo. */\nconst PEER_GRADIENTS: [string, string][] = [\n [\"#FF845E\", \"#D45246\"],\n [\"#FEBB5B\", \"#F68136\"],\n [\"#B694F9\", \"#6C61DF\"],\n [\"#9AD164\", \"#46BA43\"],\n [\"#53EDD6\", \"#28C9B7\"],\n [\"#5CAFFA\", \"#408ACF\"],\n [\"#FF8AAC\", \"#D95574\"],\n];\n\n/**\n * A peer's colour, picked the way Telegram picks it.\n *\n * Telegram keys the gradient off the peer id modulo seven. There is no peer\n * id here, so the name stands in: the point is that the same name always\n * lands on the same colour, which is what makes a chat list look settled\n * rather than randomly painted.\n */\nconst peerGradient = (name: string): [string, string] => {\n let hash = 0;\n for (const character of name) hash = (hash * 31 + character.codePointAt(0)!) >>> 0;\n return PEER_GRADIENTS[hash % PEER_GRADIENTS.length];\n};\n\n/** Telegram shows two initials where a name has two words, otherwise one. */\nconst initials = (name: string) => {\n const words = name.trim().split(/\\s+/).filter(Boolean);\n if (words.length === 0) return \"\";\n if (words.length === 1) return words[0].slice(0, 1).toUpperCase();\n return (words[0][0] + words[words.length - 1][0]).toUpperCase();\n};\n\n/**\n * A Telegram client with a bot answering.\n *\n * Bubbles are the whole language, so they behave like bubbles: each scales up\n * from the corner it is anchored to rather than fading, which is what a\n * messenger does and what makes a still frame read as a phone. The chat list\n * beside it is what makes it a client rather than a widget, and the header is\n * where a messenger reports that the other side is typing.\n */\nexport const Telegram = ({\n chats = [\n {name: \"Odori\", preview: \"Yesterday reused cached chunks…\", time: \"09:39\", unread: 1},\n {name: \"John Doe\", preview: \"sounds good\", time: \"09:12\"},\n {name: \"Release notes\", preview: \"0.0.3 is out\", time: \"Yesterday\"},\n ],\n title = \"Odori\",\n status = \"bot\",\n bubbles = [{from: \"you\", body: \"why is the nightly export bigger than yesterday?\", time: \"09:38\"}],\n reply = {\n from: \"them\",\n body: \"Yesterday reused cached chunks. Today re-encoded at studio quality after the toolchain pin changed.\",\n time: \"09:39\",\n },\n thinkingFrames = 30,\n charactersPerSecond = 34,\n}: TelegramProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n /* The typing belongs to the person at the keyboard. They compose the last\n bubble in the composer and send it; the bot's answer arrives whole. */\n const last = bubbles[bubbles.length - 1];\n const earlier = bubbles.slice(0, -1);\n const first = 18;\n const step = 18;\n const typeFrom = first + earlier.length * step;\n const typed = useTyping(last?.body ?? \"\", {from: typeFrom, charactersPerSecond, chunk: 2});\n const sendAt = typeFrom + typingFrames(last?.body ?? \"\", {charactersPerSecond}) + 10;\n const workingAt = sendAt + 12;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const landed = spring({frame, fps, delayInFrames: replyAt, stiffness: 170, damping: 15});\n const sent = frame >= sendAt;\n const working = frame >= workingAt && frame < replyAt;\n\n const bubble = (item: TelegramBubble, grown: number, body: ReactNode, key?: string) => {\n const mine = item.from === \"you\";\n return (\n <div\n key={key}\n style={{\n display: \"flex\",\n justifyContent: mine ? \"flex-end\" : \"flex-start\",\n opacity: grown,\n padding: `${px(6)}px ${px(26)}px`,\n }}\n >\n <div\n style={{\n background: mine ? YOU : THEM,\n borderBottomLeftRadius: mine ? px(16) : px(6),\n borderBottomRightRadius: mine ? px(6) : px(16),\n borderRadius: px(16),\n color: INK,\n fontSize: px(22),\n lineHeight: LINE,\n maxWidth: \"70%\",\n padding: `${px(11)}px ${px(16)}px ${px(9)}px`,\n position: \"relative\",\n transform: `scale(${0.85 + grown * 0.15})`,\n transformOrigin: mine ? \"100% 100%\" : \"0% 100%\",\n }}\n >\n {body}\n {item.time ? (\n <>\n {/* A spacer on the last line, so the text wraps around the\n time instead of running underneath it. */}\n <span style={{display: \"inline-block\", height: px(1), width: px(64)}} />\n <span\n style={{\n bottom: px(8),\n color: mine ? \"rgba(255,255,255,0.6)\" : FAINT,\n fontSize: px(16),\n position: \"absolute\",\n right: px(16),\n }}\n >\n {item.time}\n </span>\n </>\n ) : null}\n </div>\n </div>\n );\n };\n\n return (\n <Fill style={{background: \"#000000\", padding: px(60)}}>\n <div\n style={{\n borderRadius: px(14),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The chat list. */}\n <div style={{background: RAIL, borderRight: `${px(1)}px solid ${EDGE}`, flex: \"none\", width: px(400)}}>\n <div\n style={{\n background: FIELD,\n borderRadius: px(999),\n color: FAINT,\n fontSize: px(19),\n margin: `${px(20)}px ${px(18)}px`,\n padding: `${px(11)}px ${px(18)}px`,\n }}\n >\n Search\n </div>\n {chats.map((chat, index) => (\n <div\n key={chat.name}\n style={{\n alignItems: \"center\",\n background: index === 0 ? ACCENT : \"transparent\",\n display: \"flex\",\n gap: px(14),\n padding: `${px(14)}px ${px(18)}px`,\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: `linear-gradient(${peerGradient(chat.name)[0]}, ${peerGradient(chat.name)[1]})`,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(20),\n fontWeight: 500,\n height: px(54),\n justifyContent: \"center\",\n width: px(54),\n }}\n >\n {initials(chat.name)}\n </span>\n <div style={{minWidth: 0, width: \"100%\"}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(10)}}>\n <span style={{color: INK, fontSize: px(20), fontWeight: 600}}>{chat.name}</span>\n <span style={{color: index === 0 ? \"rgba(255,255,255,0.75)\" : FAINT, fontSize: px(16), marginLeft: \"auto\"}}>\n {chat.time}\n </span>\n </div>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(8), marginTop: px(3)}}>\n <span\n style={{\n color: index === 0 ? \"rgba(255,255,255,0.75)\" : FAINT,\n fontSize: px(18),\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {chat.preview}\n </span>\n {chat.unread ? (\n <span\n style={{\n background: \"#FFFFFF\",\n borderRadius: px(999),\n color: ACCENT,\n fontSize: px(15),\n fontWeight: 700,\n marginLeft: \"auto\",\n padding: `${px(1)}px ${px(8)}px`,\n }}\n >\n {chat.unread}\n </span>\n ) : null}\n </div>\n </div>\n </div>\n ))}\n </div>\n\n {/* The chat. */}\n <div style={{background: PAGE, display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div style={{alignItems: \"center\", background: RAIL, borderBottom: `${px(1)}px solid ${EDGE}`, display: \"flex\", gap: px(14), padding: `${px(16)}px ${px(24)}px`}}>\n <span\n style={{\n alignItems: \"center\",\n background: `linear-gradient(${peerGradient(title)[0]}, ${peerGradient(title)[1]})`,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(19),\n fontWeight: 500,\n height: px(50),\n justifyContent: \"center\",\n width: px(50),\n }}\n >\n {initials(title)}\n </span>\n <div>\n <div style={{color: INK, fontSize: px(22), fontWeight: 600}}>{title}</div>\n <div style={{color: working ? ACCENT : FAINT, fontSize: px(18), marginTop: px(2)}}>\n {working ? \"typing…\" : status}\n </div>\n </div>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", justifyContent: \"flex-end\", paddingBottom: px(14)}}>\n {earlier.map((item, index) =>\n bubble(\n item,\n spring({frame, fps, delayInFrames: first + index * step, stiffness: 170, damping: 15}),\n item.body,\n `${item.from}-${index}`,\n ),\n )}\n {last && sent\n ? bubble(\n last,\n spring({frame, fps, delayInFrames: sendAt, stiffness: 170, damping: 15}),\n last.body,\n \"sent\",\n )\n : null}\n {frame >= replyAt\n ? bubble(\n reply,\n landed,\n reply.body,\n )\n : null}\n </div>\n\n {/* The composer, with the controls Telegram keeps in it: a smiley\n on the left, a paperclip and a mic on the right. */}\n <div\n style={{\n alignItems: \"center\",\n background: RAIL,\n borderTop: `${px(1)}px solid ${EDGE}`,\n color: FAINT,\n display: \"flex\",\n fontSize: px(22),\n gap: px(16),\n padding: `${px(14)}px ${px(20)}px`,\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(26)} height={px(26)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.7} style={{flex: \"none\"}}>\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <circle cx=\"9\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <circle cx=\"15\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <path d=\"M8.5 14a4.5 4.5 0 0 0 7 0\" strokeLinecap=\"round\" />\n </svg>\n <span style={{flex: 1, minWidth: 0}}>\n {sent ? (\n \"Message\"\n ) : (\n <span style={{color: INK}}>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: INK,\n display: \"inline-block\",\n height: px(21),\n marginLeft: px(2),\n transform: `translateY(${px(3)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </span>\n )}\n </span>\n <svg viewBox=\"0 0 24 24\" width={px(26)} height={px(26)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.7} style={{flex: \"none\"}}>\n <path d=\"M20 11.5 12.5 19a4.6 4.6 0 0 1-6.5-6.5l7.8-7.8a3 3 0 0 1 4.3 4.3l-7.8 7.8a1.5 1.5 0 0 1-2.1-2.1l7.2-7.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n <svg viewBox=\"0 0 24 24\" width={px(26)} height={px(26)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.7} style={{flex: \"none\"}}>\n <rect x=\"9\" y=\"3\" width=\"6\" height=\"11\" rx=\"3\" />\n <path d=\"M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3\" strokeLinecap=\"round\" />\n </svg>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
4011
+ "target": "videos/components/telegram/telegram.tsx"
4012
+ },
4013
+ {
4014
+ "path": "components/telegram/telegram.preview.tsx",
4015
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Telegram} from \"./telegram\";\n\nexport default defineComponentPreview({\n title: \"Telegram\",\n category: \"Products\",\n description: \"A Telegram client with the chat list beside a bot answering in the open chat.\",\n component: Telegram,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Odori\", maxLength: 24},\n status: {type: \"text\", defaultValue: \"bot\", maxLength: 24},\n thinkingFrames: {type: \"number\", defaultValue: 30, min: 0, max: 90, step: 2},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {\n name: \"Short exchange\",\n props: {\n bubbles: [{from: \"you\", body: \"status?\", time: \"09:41\"}],\n reply: {from: \"them\", body: \"All green. Export finished 4 minutes ago.\", time: \"09:41\"},\n thinkingFrames: 18,\n },\n },\n ],\n});\n",
4016
+ "target": "videos/components/telegram/telegram.preview.tsx"
4017
+ }
4018
+ ],
4019
+ "meta": {
4020
+ "kind": "component",
4021
+ "family": "Products",
4022
+ "namespaced": "@odori/telegram",
4023
+ "contract": {
4024
+ "aspectRatios": [
4025
+ "16:9"
4026
+ ],
4027
+ "recommendedDurationInFrames": 270,
4028
+ "minimumDurationInFrames": 150,
4029
+ "entranceFrames": 14,
4030
+ "exitFrames": 12,
4031
+ "contentLimits": {
4032
+ "title": 24,
4033
+ "status": 24
4034
+ },
4035
+ "reducedMotion": "bubbles placed, no grow or typing line",
4036
+ "requires": {
4037
+ "fonts": [
4038
+ "sans"
4039
+ ],
4040
+ "audio": []
4041
+ }
4042
+ }
4043
+ }
4044
+ },
2819
4045
  {
2820
4046
  "name": "terminal",
2821
4047
  "description": "A typed command session with deterministic output reveals.",
@@ -2828,7 +4054,7 @@
2828
4054
  },
2829
4055
  {
2830
4056
  "path": "components/terminal/terminal.preview.tsx",
2831
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Terminal} from \"./terminal\";\n\nexport default defineComponentPreview({\n title: \"Terminal\",\n category: \"Developer proof\",\n description: \"A typed command session with deterministic output reveals.\",\n component: Terminal,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"zsh\"},\n prompt: {type: \"text\", defaultValue: \"$\", maxLength: 4},\n },\n examples: [\n {\n name: \"Install and preview\",\n props: {\n steps: [\n {command: \"pnpm create odori@latest product-stories\", output: [\"Created videos/launch/video.tsx\"]},\n {command: \"pnpm odori dev\", output: [\"Odori Studio ready on http://127.0.0.1:4300\"]},\n ],\n },\n },\n {\n name: \"Single command\",\n props: {steps: [{command: \"pnpm odori export launch\", output: [\"out/launch.mp4\"]}]},\n },\n ],\n});\n",
4057
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Terminal} from \"./terminal\";\n\nexport default defineComponentPreview({\n title: \"Terminal\",\n category: \"Developer proof/Terminal\",\n description: \"A typed command session with deterministic output reveals.\",\n component: Terminal,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"zsh\"},\n prompt: {type: \"text\", defaultValue: \"$\", maxLength: 4},\n },\n examples: [\n {\n name: \"Install and preview\",\n props: {\n steps: [\n {command: \"pnpm create odori@latest product-stories\", output: [\"Created videos/launch/video.tsx\"]},\n {command: \"pnpm odori dev\", output: [\"Odori Studio ready on http://127.0.0.1:4300\"]},\n ],\n },\n },\n {\n name: \"Single command\",\n props: {steps: [{command: \"pnpm odori export launch\", output: [\"out/launch.mp4\"]}]},\n },\n ],\n});\n",
2832
4058
  "target": "videos/components/terminal/terminal.preview.tsx"
2833
4059
  }
2834
4060
  ],
@@ -2859,6 +4085,49 @@
2859
4085
  }
2860
4086
  }
2861
4087
  },
4088
+ {
4089
+ "name": "terminal-zoom",
4090
+ "description": "A command typed under a camera that pushes in to read it and pulls back to place it.",
4091
+ "registryDependencies": [],
4092
+ "files": [
4093
+ {
4094
+ "path": "components/terminal-zoom/terminal-zoom.tsx",
4095
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping, typingFrames} from \"odori\";\n\nexport type TerminalZoomProps = {\n /** The command typed at the prompt. */\n command: string;\n /** The window title, the way a shell titles its tab. */\n title?: string;\n /** How close the camera pushes while it tracks the caret. */\n zoom?: number;\n /** What the shell prints before the command. */\n prompt?: string;\n /** Line printed under the command once it finishes. */\n output?: string;\n charactersPerSecond?: number;\n};\n\n/** Monospace advance width as a fraction of the font size, near enough for\n * every mono face we ship, and the only geometry the camera needs. */\nconst ADVANCE = 0.6;\n\n/**\n * A command typed under a camera that pushes in to read it and pulls back to\n * show where it was.\n *\n * A camera locked at magnification for the whole shot shows you letters and\n * never the room. This one earns the detail and then returns it: it opens on\n * the whole window, dollies in as the first characters land, tracks the caret\n * while the line is written, and eases back out once the command is finished\n * so the frame ends on the thing in context. Typing lands character by\n * character while the camera moves continuously, which is what keeps the push\n * smooth under text that is inherently steppy.\n */\nexport const TerminalZoom = ({\n command,\n title = \"~/code/odori\",\n zoom = 2.4,\n prompt = \"$\",\n output,\n charactersPerSecond = 14,\n}: TerminalZoomProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const from = 24;\n const typed = useTyping(command, {from, charactersPerSecond});\n const typingEnds = from + typingFrames(command, {charactersPerSecond});\n\n // Design geometry, in composition pixels before the canvas scale.\n const fontSize = 30;\n const advance = fontSize * ADVANCE;\n const view = 1240;\n const lead = `${prompt} `.length;\n\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n // In as the line starts, out once it is written: the push is a sentence with\n // a beginning and an end rather than a state the shot is stuck in.\n const push = interpolate(\n frame,\n [from - 6, from + 14, typingEnds + 14, typingEnds + 40],\n [1, zoom, zoom, 1],\n {easing: Easing.standard},\n );\n\n // The camera reads a continuous character count while the text lands in\n // whole characters, so it glides where the letters step.\n const continuous = Math.max(0, Math.min(command.length, ((frame - from) / 30) * charactersPerSecond));\n const caret = (lead + continuous) * advance;\n const lineWidth = (lead + command.length) * advance + advance * 2;\n // Keep the caret centred, but never pull past either end of the line.\n const centred = view / 2 - caret * push;\n const offset = Math.min(0, Math.max(centred, view - Math.max(lineWidth, view) * push));\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#0B0B0C\", justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n background: \"#151517\",\n border: `${px(1)}px solid #26262A`,\n borderRadius: px(16),\n boxShadow: `0 ${px(30)}px ${px(90)}px rgba(0,0,0,0.6)`,\n maxWidth: px(1360),\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(16)}px)`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n borderBottom: `${px(1)}px solid #26262A`,\n color: \"#75757D\",\n display: \"flex\",\n fontFamily: brand.typography.mono,\n fontSize: px(20),\n gap: px(14),\n padding: `${px(16)}px ${px(22)}px`,\n }}\n >\n <span style={{display: \"flex\", gap: px(8)}}>\n {[0, 1, 2].map((dot) => (\n <span key={dot} style={{background: \"#2D2D32\", borderRadius: px(999), height: px(11), width: px(11)}} />\n ))}\n </span>\n {title}\n </div>\n\n {/* The camera. Only this element moves; the window frame stays put, so\n the push reads as a lens rather than the whole set sliding. */}\n <div style={{height: px(300), overflow: \"hidden\", position: \"relative\"}}>\n <div\n style={{\n fontFamily: brand.typography.mono,\n fontSize: px(fontSize),\n left: 0,\n position: \"absolute\",\n top: \"50%\",\n transform: `translate(${px(offset)}px, -50%) scale(${push})`,\n transformOrigin: \"0 50%\",\n whiteSpace: \"pre\",\n }}\n >\n <div style={{color: \"#E6E6EA\"}}>\n <span style={{color: \"#5BC97F\"}}>{prompt} </span>\n {typed.text}\n <span\n style={{\n background: typed.caret ? \"#E6E6EA\" : \"transparent\",\n display: \"inline-block\",\n height: px(fontSize),\n transform: `translateY(${px(6)}px)`,\n width: px(advance),\n }}\n />\n </div>\n {output ? (\n <div\n style={{\n color: \"#75757D\",\n marginTop: px(18),\n opacity: interpolate(frame, [typingEnds + 6, typingEnds + 18], [0, 1], {easing: Easing.standard}),\n }}\n >\n {output}\n </div>\n ) : null}\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
4096
+ "target": "videos/components/terminal-zoom/terminal-zoom.tsx"
4097
+ },
4098
+ {
4099
+ "path": "components/terminal-zoom/terminal-zoom.preview.tsx",
4100
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {TerminalZoom} from \"./terminal-zoom\";\n\nexport default defineComponentPreview({\n title: \"Terminal zoom\",\n category: \"Developer proof/Terminal\",\n description: \"A command typed under a camera that pushes in to read it and pulls back to place it.\",\n component: TerminalZoom,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n command: {type: \"text\", defaultValue: \"npm create odori@latest\", maxLength: 72},\n title: {type: \"text\", defaultValue: \"~/code/odori\", maxLength: 40},\n prompt: {type: \"text\", defaultValue: \"$\", maxLength: 8},\n output: {type: \"text\", defaultValue: \"\", maxLength: 72},\n zoom: {type: \"number\", defaultValue: 2.4, min: 1, max: 4, step: 0.1},\n charactersPerSecond: {type: \"number\", defaultValue: 14, min: 4, max: 40, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"With output\", props: {command: \"odori export launch\", output: \"ok out/launch.mp4\"}},\n {name: \"Close\", props: {zoom: 3.2, command: \"odori add terminal-zoom\"}},\n ],\n});\n",
4101
+ "target": "videos/components/terminal-zoom/terminal-zoom.preview.tsx"
4102
+ }
4103
+ ],
4104
+ "meta": {
4105
+ "kind": "component",
4106
+ "family": "Developer proof",
4107
+ "namespaced": "@odori/terminal-zoom",
4108
+ "contract": {
4109
+ "aspectRatios": [
4110
+ "16:9"
4111
+ ],
4112
+ "recommendedDurationInFrames": 240,
4113
+ "minimumDurationInFrames": 120,
4114
+ "entranceFrames": 14,
4115
+ "exitFrames": 26,
4116
+ "contentLimits": {
4117
+ "command": 72,
4118
+ "title": 40,
4119
+ "output": 72
4120
+ },
4121
+ "reducedMotion": "camera held at 1x, no push",
4122
+ "requires": {
4123
+ "fonts": [
4124
+ "mono"
4125
+ ],
4126
+ "audio": []
4127
+ }
4128
+ }
4129
+ }
4130
+ },
2862
4131
  {
2863
4132
  "name": "timeline",
2864
4133
  "description": "Milestones revealed along a measured temporal axis.",
@@ -2914,7 +4183,7 @@
2914
4183
  },
2915
4184
  {
2916
4185
  "path": "components/title-reveal/title-reveal.preview.tsx",
2917
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {TitleReveal} from \"./title-reveal\";\n\nexport default defineComponentPreview({\n title: \"Title reveal\",\n category: \"Typography\",\n description: \"A staggered, word-masked headline.\",\n component: TitleReveal,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Ship the story.\", maxLength: 64},\n detail: {type: \"text\", defaultValue: \"An independent React video framework.\"},\n align: {type: \"select\", defaultValue: \"center\", options: [\"left\", \"center\"]},\n },\n examples: [\n {name: \"Default\", props: {title: \"Ship the story.\"}},\n {name: \"Two lines\", props: {title: \"Build videos\\nlike applications.\", detail: \"Author. Preview. Ship.\"}},\n {name: \"Long copy\", props: {title: \"Build videos like applications, not like exports.\", align: \"left\"}},\n ],\n});\n",
4186
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {TitleReveal} from \"./title-reveal\";\n\nexport default defineComponentPreview({\n title: \"Title reveal\",\n category: \"Typography/Titles\",\n description: \"A staggered, word-masked headline.\",\n component: TitleReveal,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Ship the story.\", maxLength: 64},\n detail: {type: \"text\", defaultValue: \"An independent React video framework.\"},\n align: {type: \"select\", defaultValue: \"center\", options: [\"left\", \"center\"]},\n },\n examples: [\n {name: \"Default\", props: {title: \"Ship the story.\"}},\n {name: \"Two lines\", props: {title: \"Build videos\\nlike applications.\", detail: \"Author. Preview. Ship.\"}},\n {name: \"Long copy\", props: {title: \"Build videos like applications, not like exports.\", align: \"left\"}},\n ],\n});\n",
2918
4187
  "target": "videos/components/title-reveal/title-reveal.preview.tsx"
2919
4188
  }
2920
4189
  ],
@@ -2957,13 +4226,13 @@
2957
4226
  },
2958
4227
  {
2959
4228
  "path": "components/toast-stack/toast-stack.preview.tsx",
2960
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ToastStack} from \"./toast-stack\";\n\nexport default defineComponentPreview({\n title: \"Toast stack\",\n category: \"Product UI\",\n description: \"Timed notifications with entry, hold, and dismissal.\",\n component: ToastStack,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n corner: {\n type: \"select\",\n defaultValue: \"bottom-right\",\n options: [\"bottom-right\", \"top-right\", \"bottom-left\", \"top-left\"],\n },\n },\n examples: [\n {\n name: \"Default\",\n props: {\n toasts: [\n {at: 10, title: \"Build started\", detail: \"odori.dev · production\", tone: \"neutral\"},\n {at: 45, title: \"Tests passed\", detail: \"142 checks\", tone: \"positive\", hold: 60},\n {at: 90, title: \"Deployed\", detail: \"Live in 12s\", tone: \"positive\", hold: 70},\n ],\n },\n },\n {\n name: \"Failure\",\n props: {\n toasts: [\n {at: 10, title: \"Build started\", tone: \"neutral\"},\n {at: 50, title: \"Build failed\", detail: \"Type error in schema.ts\", tone: \"negative\", hold: 90},\n ],\n },\n },\n ],\n});\n",
4229
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ToastStack} from \"./toast-stack\";\n\nexport default defineComponentPreview({\n title: \"Toast stack\",\n category: \"Interface/Feedback\",\n description: \"Timed notifications with entry, hold, and dismissal.\",\n component: ToastStack,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n corner: {\n type: \"select\",\n defaultValue: \"bottom-right\",\n options: [\"bottom-right\", \"top-right\", \"bottom-left\", \"top-left\"],\n },\n },\n examples: [\n {\n name: \"Default\",\n props: {\n toasts: [\n {at: 10, title: \"Build started\", detail: \"odori.dev · production\", tone: \"neutral\"},\n {at: 45, title: \"Tests passed\", detail: \"142 checks\", tone: \"positive\", hold: 60},\n {at: 90, title: \"Deployed\", detail: \"Live in 12s\", tone: \"positive\", hold: 70},\n ],\n },\n },\n {\n name: \"Failure\",\n props: {\n toasts: [\n {at: 10, title: \"Build started\", tone: \"neutral\"},\n {at: 50, title: \"Build failed\", detail: \"Type error in schema.ts\", tone: \"negative\", hold: 90},\n ],\n },\n },\n ],\n});\n",
2961
4230
  "target": "videos/components/toast-stack/toast-stack.preview.tsx"
2962
4231
  }
2963
4232
  ],
2964
4233
  "meta": {
2965
4234
  "kind": "component",
2966
- "family": "Product UI",
4235
+ "family": "Interface",
2967
4236
  "namespaced": "@odori/toast-stack",
2968
4237
  "contract": {
2969
4238
  "aspectRatios": [
@@ -3001,7 +4270,7 @@
3001
4270
  },
3002
4271
  {
3003
4272
  "path": "components/typewriter/typewriter.preview.tsx",
3004
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Typewriter} from \"./typewriter\";\n\nexport default defineComponentPreview({\n title: \"Typewriter\",\n category: \"Typography\",\n description: \"Deterministic typing with a frame-driven caret.\",\n component: Typewriter,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"pnpm odori dev\", maxLength: 48},\n label: {type: \"text\", defaultValue: \"one command\"},\n charactersPerSecond: {type: \"number\", defaultValue: 18, min: 4, max: 40},\n },\n examples: [\n {name: \"Command\", props: {text: \"pnpm odori dev\", label: \"one command\"}},\n {name: \"Export\", props: {text: \"pnpm odori export launch\", charactersPerSecond: 26}},\n ],\n});\n",
4273
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Typewriter} from \"./typewriter\";\n\nexport default defineComponentPreview({\n title: \"Typewriter\",\n category: \"Typography/Titles\",\n description: \"Deterministic typing with a frame-driven caret.\",\n component: Typewriter,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"pnpm odori dev\", maxLength: 48},\n label: {type: \"text\", defaultValue: \"one command\"},\n charactersPerSecond: {type: \"number\", defaultValue: 18, min: 4, max: 40},\n },\n examples: [\n {name: \"Command\", props: {text: \"pnpm odori dev\", label: \"one command\"}},\n {name: \"Export\", props: {text: \"pnpm odori export launch\", charactersPerSecond: 26}},\n ],\n});\n",
3005
4274
  "target": "videos/components/typewriter/typewriter.preview.tsx"
3006
4275
  }
3007
4276
  ],
@@ -3033,33 +4302,28 @@
3033
4302
  }
3034
4303
  },
3035
4304
  {
3036
- "name": "typing-loop",
3037
- "description": "Keystrokes that repeat under a terminal scene.",
4305
+ "name": "typing",
4306
+ "description": "Someone actually typing. A recording, because synthesis kept producing a metronome.",
3038
4307
  "registryDependencies": [],
3039
4308
  "files": [
3040
4309
  {
3041
- "path": "components/typing-loop/typing-loop.tsx",
3042
- "content": "import {defineCue, gain, highPass, mix, noise, normalize, seeded, sequence, shape, sine, type CueDefinition} from \"odori\";\n\nexport type TypingLoopOptions = {\n /** Keystrokes per minute, as a tempo. */\n bpm?: number;\n /** How irregular the timing is, 0 to 1. Nobody types on a grid. */\n human?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Someone typing: a bar of keystrokes that repeats for as long as the terminal\n * is on screen. Loops, so a scene of any length is one render.\n *\n * The timing and the tone of each key are jittered from a seeded generator.\n * Perfectly even keystrokes read as a machine, and a machine typing is the one\n * thing this sound must not suggest.\n */\nexport const typingLoop = ({bpm = 300, human = 0.35, peak = 0.5}: TypingLoopOptions = {}): CueDefinition => {\n const random = seeded(19);\n const keys = 16;\n const samples = Math.round((60 / bpm) * keys * 48000);\n\n return defineCue({\n name: \"ui.typing\",\n durationInFrames: Math.round((samples / 48000) * 30),\n loops: true,\n params: {bpm, human, peak},\n render: () =>\n normalize(\n sequence(\n Array.from({length: keys}, (_, index) => {\n const jitter = (random() - 0.5) * human;\n const tone = 260 + random() * 220;\n return {\n // The jitter is bounded so a key never crosses its neighbour.\n at: index + Math.max(-0.4, Math.min(0.4, jitter)),\n gain: 0.6 + random() * 0.4,\n play: (length: number) =>\n mix(\n shape(highPass(noise(length, index + 2), 2200), {attack: 0.001, decay: 0.035, sustain: 0}),\n gain(shape(sine(length, tone), {attack: 0.001, decay: 0.028, sustain: 0}), 0.5),\n ),\n };\n }),\n {bpm, samples},\n ),\n peak,\n ),\n });\n};\n",
3043
- "target": "videos/components/typing-loop/typing-loop.tsx"
3044
- },
3045
- {
3046
- "path": "components/typing-loop/typing-loop.preview.tsx",
3047
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {typingLoop, type TypingLoopOptions} from \"./typing-loop\";\n\nconst Wave = ({bpm, human, peak}: TypingLoopOptions) => <CueWave cue={typingLoop({bpm, human, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Typing\",\n category: \"Sound\",\n description: \"Keystrokes that repeat under a terminal scene.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n bpm: {type: \"number\", defaultValue: 300, min: 120, max: 600},\n human: {type: \"number\", defaultValue: 0.35, min: 0, max: 1, step: 0.05},\n peak: {type: \"number\", defaultValue: 0.5, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Mechanical\", props: {human: 0}}],\n});\n",
3048
- "target": "videos/components/typing-loop/typing-loop.preview.tsx"
4310
+ "path": "components/typing/typing.preview.tsx",
4311
+ "content": "import {SampleWave, defineComponentPreview} from \"odori/preview\";\n\n/**\n * Peaks measured from the published file, so the card draws the recording\n * rather than a decorative squiggle. Regenerate them if the file is recut.\n */\nconst PEAKS = [\n 0, 0, 0.07, 0.01, 0, 0, 0, 0.05, 0.07, 0.06, 0.33, 0.4, 0.08, 0.01, 0, 0.28, 0.03, 0.49, 0.34, 0.36, 0.15, 0.66,\n 0.31, 0.33, 0.02, 0, 0, 0, 0.3, 0.41, 0.35, 0.24, 0.22, 0.25, 0.7, 0.19, 0.27, 0.55, 0.08, 0.39, 0, 0, 0, 0, 0,\n 0.24, 0.26, 0.84, 0.11, 0.27, 0.08, 0.18, 0.01, 0.1, 0.01, 0, 0, 0, 0, 0.96, 0.01, 0.36, 0.38, 0.19, 0.45, 0.3,\n 0.35, 0.01, 0, 0, 0, 0, 0.02, 0.15, 0.01, 0.33, 0.16, 0.6, 0.4, 0.05, 0.01, 0.04, 0.04, 0.31, 0.31, 0.42, 0.01,\n 0, 0.15, 0.01, 0, 0.28, 0.38, 0.53, 0.01, 0.39, 0.14, 0.36, 0.49, 0.02, 0.02, 0, 0.92, 0.13, 0.01, 0.39, 0.2,\n 0.08, 0.37, 0.23, 0, 0, 0, 0, 0, 0.23, 0.58, 0.26, 0.15, 0.24,\n];\n\nexport default defineComponentPreview({\n title: \"Typing\",\n category: \"Sound/Foley\",\n description: \"Someone actually typing. A recording, because synthesis kept producing a metronome.\",\n component: () => <SampleWave peaks={PEAKS} label=\"ui.typing\" seconds={15} loops />,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n examples: [{name: \"Default\", props: {}}],\n});\n",
4312
+ "target": "videos/components/typing/typing.preview.tsx"
3049
4313
  }
3050
4314
  ],
3051
4315
  "meta": {
3052
- "kind": "cue",
4316
+ "kind": "asset",
3053
4317
  "family": "Sound",
3054
- "namespaced": "@odori/typing-loop",
4318
+ "namespaced": "@odori/typing",
3055
4319
  "contract": {
3056
4320
  "aspectRatios": [
3057
4321
  "16:9",
3058
4322
  "9:16",
3059
4323
  "1:1"
3060
4324
  ],
3061
- "recommendedDurationInFrames": 96,
3062
- "minimumDurationInFrames": 96,
4325
+ "recommendedDurationInFrames": 445,
4326
+ "minimumDurationInFrames": 30,
3063
4327
  "entranceFrames": 0,
3064
4328
  "exitFrames": 0,
3065
4329
  "contentLimits": {},
@@ -3071,31 +4335,22 @@
3071
4335
  "audio": []
3072
4336
  },
3073
4337
  "loops": true
3074
- },
3075
- "cue": {
3076
- "name": "ui.typing",
3077
- "export": "typingLoop"
3078
4338
  }
3079
4339
  }
3080
4340
  },
3081
4341
  {
3082
4342
  "name": "ui-key",
3083
- "description": "A typed key: filtered noise with a short tone under it.",
4343
+ "description": "A single keystroke, recorded. The click of a real key, for a terminal beat.",
3084
4344
  "registryDependencies": [],
3085
4345
  "files": [
3086
- {
3087
- "path": "components/ui-key/ui-key.tsx",
3088
- "content": "import {\n defineCue,\n mix,\n noise,\n normalize,\n shape,\n sine,\n type CueDefinition,\n} from \"odori\";\n\nexport type UiKeyOptions = {\n /** How much body the click keeps. Lower is duller, higher is sharper. */\n tone?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * A key press: filtered noise for the click, a short sine underneath for\n * weight. Six frames, so it lands inside a single typed character.\n *\n * A sound as source. Change the numbers, hear it in the next preview frame,\n * and the encoder mixes the samples you approved.\n */\nexport const uiKey = ({tone = 320, peak = 0.7}: UiKeyOptions = {}): CueDefinition =>\n defineCue({\n name: \"ui.key\",\n durationInFrames: 6,\n params: {tone, peak},\n render: ({samples}) =>\n normalize(\n mix(\n shape(noise(samples, 7), {attack: 0.001, decay: 0.05, sustain: 0, release: 0.02}),\n shape(sine(samples, tone), {attack: 0.001, decay: 0.035, sustain: 0, release: 0.02}),\n ),\n peak,\n ),\n });\n",
3089
- "target": "videos/components/ui-key/ui-key.tsx"
3090
- },
3091
4346
  {
3092
4347
  "path": "components/ui-key/ui-key.preview.tsx",
3093
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {uiKey, type UiKeyOptions} from \"./ui-key\";\n\nconst Wave = ({tone, peak}: UiKeyOptions) => <CueWave cue={uiKey({tone, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Key click\",\n category: \"Sound\",\n description: \"A typed key: filtered noise with a short tone under it.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n tone: {type: \"number\", defaultValue: 320, min: 80, max: 900},\n peak: {type: \"number\", defaultValue: 0.7, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Softer\", props: {tone: 180, peak: 0.5}},\n ],\n});\n",
4348
+ "content": "import {SampleWave, defineComponentPreview} from \"odori/preview\";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [\n 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.01, 0.06, 0.06, 0.05, 0.79, 1,\n 0.73, 0.22, 0.26, 0.12, 0.13, 0.06, 0.09, 0.08, 0.1, 0.08, 0.03, 0.05, 0.08, 0.07, 0.03, 0.05, 0.05, 0.05, 0.04,\n 0.03, 0.05, 0.04, 0.01, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0, 0.02, 0.03, 0.03, 0.01, 0.01, 0.01, 0.01,\n 0.01, 0.01, 0.02, 0.02, 0.02, 0.01, 0.02, 0.03, 0.03, 0.02, 0.02, 0.02, 0.02, 0.01, 0, 0, 0, 0, 0.01, 0.01,\n 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0, 0.01, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0, 0.01, 0, 0, 0, 0, 0,\n 0, 0, 0, 0, 0, 0, 0, 0, 0,\n];\n\nexport default defineComponentPreview({\n title: \"Key click\",\n category: \"Sound/Foley\",\n description: \"A single keystroke, recorded. The click of a real key, for a terminal beat.\",\n component: () => <SampleWave peaks={PEAKS} label=\"ui.key\" />,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n examples: [{name: \"Default\", props: {}}],\n});\n",
3094
4349
  "target": "videos/components/ui-key/ui-key.preview.tsx"
3095
4350
  }
3096
4351
  ],
3097
4352
  "meta": {
3098
- "kind": "cue",
4353
+ "kind": "asset",
3099
4354
  "family": "Sound",
3100
4355
  "namespaced": "@odori/ui-key",
3101
4356
  "contract": {
@@ -3104,8 +4359,8 @@
3104
4359
  "9:16",
3105
4360
  "1:1"
3106
4361
  ],
3107
- "recommendedDurationInFrames": 6,
3108
- "minimumDurationInFrames": 6,
4362
+ "recommendedDurationInFrames": 5,
4363
+ "minimumDurationInFrames": 5,
3109
4364
  "entranceFrames": 0,
3110
4365
  "exitFrames": 0,
3111
4366
  "contentLimits": {},
@@ -3117,56 +4372,49 @@
3117
4372
  "audio": []
3118
4373
  },
3119
4374
  "loops": false
3120
- },
3121
- "cue": {
3122
- "name": "ui.key",
3123
- "export": "uiKey"
3124
4375
  }
3125
4376
  }
3126
4377
  },
3127
4378
  {
3128
- "name": "ui-reveal",
3129
- "description": "A short upward sweep that settles, for an entrance.",
4379
+ "name": "v0",
4380
+ "description": "A generative build tool taking a brief, its starters clearing as the prompt lands.",
3130
4381
  "registryDependencies": [],
3131
4382
  "files": [
3132
4383
  {
3133
- "path": "components/ui-reveal/ui-reveal.tsx",
3134
- "content": "import {\n defineCue,\n lowPass,\n mix,\n noise,\n normalize,\n shape,\n sweep,\n type CueDefinition,\n} from \"odori\";\n\nexport type UiRevealOptions = {\n /** Where the sweep starts, in hertz. */\n from?: number;\n /** Where it lands. The distance is what makes it feel like an arrival. */\n to?: number;\n peak?: number;\n};\n\n/**\n * A reveal: a short sweep upward that settles on a tone, with a breath of\n * filtered noise behind it. Eighteen frames, matched to a typical entrance.\n */\nexport const uiReveal = ({from = 220, to = 660, peak = 0.75}: UiRevealOptions = {}): CueDefinition =>\n defineCue({\n name: \"ui.reveal\",\n durationInFrames: 18,\n params: {from, to, peak},\n render: ({samples}) =>\n normalize(\n mix(\n shape(sweep(samples, from, to), {attack: 0.02, decay: 0.22, sustain: 0.3, release: 0.2}),\n shape(lowPass(noise(samples, 11), 2400), {attack: 0.05, decay: 0.25, sustain: 0.08, release: 0.25}),\n ),\n peak,\n ),\n });\n",
3135
- "target": "videos/components/ui-reveal/ui-reveal.tsx"
4384
+ "path": "components/v0/v0.tsx",
4385
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from \"odori\";\n\nexport type V0Props = {\n /** The prompt typed into the composer. */\n prompt: string;\n /** The line above the composer. */\n heading?: string;\n /** What sits in the field before the first character. */\n placeholder?: string;\n /** Starters under the composer. They clear once typing starts. */\n suggestions?: string[];\n /** The model chip inside the composer. */\n model?: string;\n charactersPerSecond?: number;\n};\n\nconst PAGE = \"#000000\";\nconst FIELD = \"#121212\";\nconst EDGE = \"#2E2E2E\";\nconst INK = \"#EDEDED\";\nconst FAINT = \"#8F8F8F\";\n/** v0 sets its interface in Geist. */\nconst FONT = '\"GeistSans\", \"Geist\", ui-sans-serif, system-ui, -apple-system, sans-serif';\n\n/**\n * A generative build tool taking a brief.\n *\n * The composer is a dark textarea rather than a single line, because what gets\n * typed here is a paragraph describing something to build, not a question. The\n * heading above it holds the frame while the text lands, and the send control\n * fills only once there is something to send, so the shot has one change in it\n * rather than three.\n */\nexport const V0 = ({\n prompt,\n heading = \"What can I help you ship?\",\n placeholder = \"Describe what you want to build…\",\n suggestions = [\"Clone a screen\", \"Landing page\", \"Dashboard\", \"Sign up form\"],\n model = \"v0-1.5-lg\",\n charactersPerSecond = 26,\n}: V0Props) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const typed = useTyping(prompt, {from: 20, charactersPerSecond, chunk: 2});\n const enter = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});\n const cleared = interpolate(typed.length > 0 ? frame : 0, [20, 30], [0, 1], {easing: Easing.standard});\n const armed = interpolate(typed.length > 0 ? frame : 0, [26, 34], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", background: PAGE, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n maxWidth: px(1120),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(16)}px)`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n color: INK,\n fontFamily: FONT,\n fontSize: px(48),\n fontWeight: 500,\n letterSpacing: \"-0.03em\",\n marginBottom: px(34),\n textAlign: \"center\",\n }}\n >\n {heading}\n </div>\n\n <div\n style={{\n background: FIELD,\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(12),\n padding: `${px(26)}px ${px(24)}px ${px(18)}px`,\n }}\n >\n <div\n style={{\n color: typed.length > 0 ? INK : FAINT,\n fontFamily: FONT,\n fontSize: px(28),\n lineHeight: 1.5,\n minHeight: px(120),\n whiteSpace: \"pre-wrap\",\n }}\n >\n {typed.length > 0 ? typed.text : placeholder}\n {typed.caret ? (\n <span\n style={{\n background: INK,\n display: \"inline-block\",\n height: px(28),\n marginLeft: px(3),\n transform: `translateY(${px(4)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", justifyContent: \"space-between\", marginTop: px(14)}}>\n <span\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(8),\n color: FAINT,\n fontFamily: brand.typography.mono,\n fontSize: px(19),\n padding: `${px(7)}px ${px(14)}px`,\n }}\n >\n {model}\n </span>\n <span\n style={{\n alignItems: \"center\",\n background: `color-mix(in srgb, #FFFFFF ${armed * 100}%, ${EDGE})`,\n borderRadius: px(10),\n display: \"inline-flex\",\n height: px(52),\n justifyContent: \"center\",\n width: px(52),\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(24)} height={px(24)}>\n <path\n d=\"M12 19V5M12 5l-6 6M12 5l6 6\"\n fill=\"none\"\n stroke={armed > 0.5 ? \"#000000\" : FAINT}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.4}\n />\n </svg>\n </span>\n </div>\n </div>\n\n {suggestions.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: px(12),\n justifyContent: \"center\",\n marginTop: px(26),\n opacity: 1 - cleared,\n transform: `translateY(${cleared * px(-10)}px)`,\n }}\n >\n {suggestions.map((item) => (\n <span\n key={item}\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(999),\n color: FAINT,\n fontFamily: FONT,\n fontSize: px(21),\n padding: `${px(11)}px ${px(20)}px`,\n }}\n >\n {item}\n </span>\n ))}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
4386
+ "target": "videos/components/v0/v0.tsx"
3136
4387
  },
3137
4388
  {
3138
- "path": "components/ui-reveal/ui-reveal.preview.tsx",
3139
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {uiReveal, type UiRevealOptions} from \"./ui-reveal\";\n\nconst Wave = ({from, to, peak}: UiRevealOptions) => <CueWave cue={uiReveal({from, to, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Reveal\",\n category: \"Sound\",\n description: \"A short upward sweep that settles, for an entrance.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n from: {type: \"number\", defaultValue: 220, min: 80, max: 800},\n to: {type: \"number\", defaultValue: 660, min: 200, max: 2000},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Wider\", props: {from: 140, to: 1200}},\n ],\n});\n",
3140
- "target": "videos/components/ui-reveal/ui-reveal.preview.tsx"
4389
+ "path": "components/v0/v0.preview.tsx",
4390
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {V0} from \"./v0\";\n\nexport default defineComponentPreview({\n title: \"v0\",\n category: \"Agents\",\n description: \"A generative build tool taking a brief, its starters clearing as the prompt lands.\",\n component: V0,\n canvas: {width: 1920, height: 1080, duration: \"7s\"},\n controls: {\n prompt: {type: \"text\", defaultValue: \"A pricing page with three tiers and a yearly toggle.\", maxLength: 140},\n heading: {type: \"text\", defaultValue: \"What can I help you ship?\", maxLength: 48},\n model: {type: \"text\", defaultValue: \"v0-1.5-lg\", maxLength: 20},\n charactersPerSecond: {type: \"number\", defaultValue: 26, min: 8, max: 60, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"No starters\", props: {suggestions: []}},\n ],\n});\n",
4391
+ "target": "videos/components/v0/v0.preview.tsx"
3141
4392
  }
3142
4393
  ],
3143
4394
  "meta": {
3144
- "kind": "cue",
3145
- "family": "Sound",
3146
- "namespaced": "@odori/ui-reveal",
4395
+ "kind": "component",
4396
+ "family": "Agents",
4397
+ "namespaced": "@odori/v0",
3147
4398
  "contract": {
3148
4399
  "aspectRatios": [
3149
- "16:9",
3150
- "9:16",
3151
- "1:1"
4400
+ "16:9"
3152
4401
  ],
3153
- "recommendedDurationInFrames": 18,
3154
- "minimumDurationInFrames": 18,
3155
- "entranceFrames": 0,
3156
- "exitFrames": 0,
3157
- "contentLimits": {},
3158
- "reducedMotion": "the waveform renders without a playhead",
4402
+ "recommendedDurationInFrames": 210,
4403
+ "minimumDurationInFrames": 90,
4404
+ "entranceFrames": 16,
4405
+ "exitFrames": 12,
4406
+ "contentLimits": {
4407
+ "prompt": 140,
4408
+ "heading": 48
4409
+ },
4410
+ "reducedMotion": "starters hidden rather than cleared",
3159
4411
  "requires": {
3160
4412
  "fonts": [
4413
+ "sans",
3161
4414
  "mono"
3162
4415
  ],
3163
4416
  "audio": []
3164
- },
3165
- "loops": false
3166
- },
3167
- "cue": {
3168
- "name": "ui.reveal",
3169
- "export": "uiReveal"
4417
+ }
3170
4418
  }
3171
4419
  }
3172
4420
  },
@@ -3182,7 +4430,7 @@
3182
4430
  },
3183
4431
  {
3184
4432
  "path": "components/value-swap/value-swap.preview.tsx",
3185
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ValueSwap} from \"./value-swap\";\n\nexport default defineComponentPreview({\n title: \"Value swap\",\n category: \"Typography\",\n description: \"Replace one label or value while preserving alignment.\",\n component: ValueSwap,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n label: {type: \"text\", defaultValue: \"Status\"},\n from: {type: \"text\", defaultValue: \"Draft\", maxLength: 24},\n to: {type: \"text\", defaultValue: \"Ready\", maxLength: 24},\n swapFrame: {type: \"number\", defaultValue: 45, min: 12, max: 80},\n },\n examples: [\n {name: \"Status\", props: {label: \"Status\", from: \"Draft\", to: \"Ready\"}},\n {name: \"Duration\", props: {label: \"Render time\", from: \"8 min\", to: \"40 s\"}},\n ],\n});\n",
4433
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ValueSwap} from \"./value-swap\";\n\nexport default defineComponentPreview({\n title: \"Value swap\",\n category: \"Typography/Figures\",\n description: \"Replace one label or value while preserving alignment.\",\n component: ValueSwap,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n label: {type: \"text\", defaultValue: \"Status\"},\n from: {type: \"text\", defaultValue: \"Draft\", maxLength: 24},\n to: {type: \"text\", defaultValue: \"Ready\", maxLength: 24},\n swapFrame: {type: \"number\", defaultValue: 45, min: 12, max: 80},\n },\n examples: [\n {name: \"Status\", props: {label: \"Status\", from: \"Draft\", to: \"Ready\"}},\n {name: \"Duration\", props: {label: \"Render time\", from: \"8 min\", to: \"40 s\"}},\n ],\n});\n",
3186
4434
  "target": "videos/components/value-swap/value-swap.preview.tsx"
3187
4435
  }
3188
4436
  ],
@@ -3226,13 +4474,13 @@
3226
4474
  },
3227
4475
  {
3228
4476
  "path": "components/video-stage/video-stage.preview.tsx",
3229
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {VideoStage} from \"./video-stage\";\n\nexport default defineComponentPreview({\n title: \"Video stage\",\n category: \"Media and canvas\",\n description: \"Nested media seeked by frame, never played by clock.\",\n component: VideoStage,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n trimStart: {type: \"number\", defaultValue: 0, min: 0, max: 30},\n rate: {type: \"number\", defaultValue: 1, min: 0.25, max: 3, step: 0.25},\n fit: {type: \"select\", defaultValue: \"cover\", options: [\"cover\", \"contain\"]},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Half speed\", props: {rate: 0.5}},\n ],\n});\n",
4477
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {VideoStage} from \"./video-stage\";\n\nexport default defineComponentPreview({\n title: \"Video stage\",\n category: \"Media/Footage\",\n description: \"Nested media seeked by frame, never played by clock.\",\n component: VideoStage,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n trimStart: {type: \"number\", defaultValue: 0, min: 0, max: 30},\n rate: {type: \"number\", defaultValue: 1, min: 0.25, max: 3, step: 0.25},\n fit: {type: \"select\", defaultValue: \"cover\", options: [\"cover\", \"contain\"]},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Half speed\", props: {rate: 0.5}},\n ],\n});\n",
3230
4478
  "target": "videos/components/video-stage/video-stage.preview.tsx"
3231
4479
  }
3232
4480
  ],
3233
4481
  "meta": {
3234
4482
  "kind": "component",
3235
- "family": "Media and canvas",
4483
+ "family": "Media",
3236
4484
  "namespaced": "@odori/video-stage",
3237
4485
  "contract": {
3238
4486
  "aspectRatios": [
@@ -3292,52 +4540,6 @@
3292
4540
  }
3293
4541
  }
3294
4542
  },
3295
- {
3296
- "name": "whoosh",
3297
- "description": "Swept air across a cut, with a direction.",
3298
- "registryDependencies": [],
3299
- "files": [
3300
- {
3301
- "path": "components/whoosh/whoosh.tsx",
3302
- "content": "import {defineCue, gain, highPass, lowPass, mix, noise, normalize, shape, sweep, type CueDefinition} from \"odori\";\n\nexport type WhooshOptions = {\n /** Where the sweep starts, in hertz. */\n from?: number;\n /** Where it ends. Above `from` rises, below it falls. */\n to?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Air moving: filtered noise swept across the cut, with a tone under it for\n * direction. Half a second, which is a transition rather than an event.\n *\n * The noise is band limited on both ends. Unfiltered noise reads as a hiss and\n * sits on top of the picture; taking the extremes off puts it behind.\n */\nexport const whoosh = ({from = 400, to = 5200, peak = 0.6}: WhooshOptions = {}): CueDefinition => {\n const samples = Math.round(48000 * 0.5);\n\n return defineCue({\n name: \"ui.whoosh\",\n durationInFrames: 15,\n params: {from, to, peak},\n render: () =>\n normalize(\n mix(\n gain(\n shape(lowPass(highPass(noise(samples, 13), 500), 9000), {attack: 0.14, decay: 0.3, sustain: 0}),\n 0.8,\n ),\n // The tone is what gives a whoosh a direction rather than a texture.\n gain(shape(sweep(samples, from, to), {attack: 0.12, decay: 0.34, sustain: 0}), 0.22),\n ),\n peak,\n ),\n });\n};\n",
3303
- "target": "videos/components/whoosh/whoosh.tsx"
3304
- },
3305
- {
3306
- "path": "components/whoosh/whoosh.preview.tsx",
3307
- "content": "import {CueWave, defineComponentPreview} from \"odori/preview\";\nimport {whoosh, type WhooshOptions} from \"./whoosh\";\n\nconst Wave = ({from, to, peak}: WhooshOptions) => <CueWave cue={whoosh({from, to, peak})} />;\n\nexport default defineComponentPreview({\n title: \"Whoosh\",\n category: \"Sound\",\n description: \"Swept air across a cut, with a direction.\",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: \"2s\"},\n controls: {\n from: {type: \"number\", defaultValue: 400, min: 100, max: 4000},\n to: {type: \"number\", defaultValue: 5200, min: 200, max: 12000},\n peak: {type: \"number\", defaultValue: 0.6, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: \"Default\", props: {}}, {name: \"Falling\", props: {from: 5200, to: 400}}],\n});\n",
3308
- "target": "videos/components/whoosh/whoosh.preview.tsx"
3309
- }
3310
- ],
3311
- "meta": {
3312
- "kind": "cue",
3313
- "family": "Sound",
3314
- "namespaced": "@odori/whoosh",
3315
- "contract": {
3316
- "aspectRatios": [
3317
- "16:9",
3318
- "9:16",
3319
- "1:1"
3320
- ],
3321
- "recommendedDurationInFrames": 15,
3322
- "minimumDurationInFrames": 15,
3323
- "entranceFrames": 0,
3324
- "exitFrames": 0,
3325
- "contentLimits": {},
3326
- "reducedMotion": "the waveform renders without a playhead",
3327
- "requires": {
3328
- "fonts": [
3329
- "mono"
3330
- ],
3331
- "audio": []
3332
- },
3333
- "loops": false
3334
- },
3335
- "cue": {
3336
- "name": "ui.whoosh",
3337
- "export": "whoosh"
3338
- }
3339
- }
3340
- },
3341
4543
  {
3342
4544
  "name": "word-cascade",
3343
4545
  "description": "Words enter in a paced vertical sequence.",
@@ -3350,7 +4552,7 @@
3350
4552
  },
3351
4553
  {
3352
4554
  "path": "components/word-cascade/word-cascade.preview.tsx",
3353
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {WordCascade} from \"./word-cascade\";\n\nexport default defineComponentPreview({\n title: \"Word cascade\",\n category: \"Typography\",\n description: \"Words enter in a paced vertical sequence.\",\n component: WordCascade,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n detail: {type: \"text\", defaultValue: \"One workflow, every launch.\"},\n stagger: {type: \"number\", defaultValue: 12, min: 4, max: 30},\n },\n examples: [\n {name: \"Default\", props: {words: [\"Write.\", \"Preview.\", \"Ship.\"], detail: \"One workflow, every launch.\"}},\n {name: \"Two beats\", props: {words: [\"Author.\", \"Render.\"], stagger: 18}},\n ],\n});\n",
4555
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {WordCascade} from \"./word-cascade\";\n\nexport default defineComponentPreview({\n title: \"Word cascade\",\n category: \"Typography/Titles\",\n description: \"Words enter in a paced vertical sequence.\",\n component: WordCascade,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n detail: {type: \"text\", defaultValue: \"One workflow, every launch.\"},\n stagger: {type: \"number\", defaultValue: 12, min: 4, max: 30},\n },\n examples: [\n {name: \"Default\", props: {words: [\"Write.\", \"Preview.\", \"Ship.\"], detail: \"One workflow, every launch.\"}},\n {name: \"Two beats\", props: {words: [\"Author.\", \"Render.\"], stagger: 18}},\n ],\n});\n",
3354
4556
  "target": "videos/components/word-cascade/word-cascade.preview.tsx"
3355
4557
  }
3356
4558
  ],