@odori/cli 0.0.11 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/{chunk-77R3UC27.js → chunk-FS2BJU5Q.js} +741 -72
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +95 -1
- package/dist/index.js +5 -1
- package/dist/{registry-snapshot-TKH2KAC3.js → registry-snapshot-TSTAA6NT.js} +1815 -245
- package/package.json +17 -20
- package/src/cli.ts +29 -0
- package/src/commands/blocks.ts +188 -0
- package/src/commands/docs.ts +88 -0
- package/src/commands/doctor.ts +38 -0
- package/src/commands/graph.ts +141 -0
- package/src/commands/test.ts +10 -1
- package/src/discovery.ts +8 -2
- package/src/docs-snapshot.json +130 -0
- package/src/docs.ts +54 -0
- package/src/index.ts +2 -0
- package/src/registry-snapshot.json +1647 -206
- package/src/registry-source.ts +139 -0
- package/src/structure.ts +177 -0
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"files": [
|
|
49
49
|
{
|
|
50
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:
|
|
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: brand.colors.background, 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 ${brand.colors.border}`}}>\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: brand.colors.muted,\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: brand.colors.muted,\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
52
|
"target": "videos/components/accordion/accordion.tsx"
|
|
53
53
|
},
|
|
54
54
|
{
|
|
@@ -123,52 +123,6 @@
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
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
|
-
},
|
|
172
126
|
{
|
|
173
127
|
"name": "b-roll-window",
|
|
174
128
|
"description": "Supporting footage framed beside the main narrative.",
|
|
@@ -176,12 +130,12 @@
|
|
|
176
130
|
"files": [
|
|
177
131
|
{
|
|
178
132
|
"path": "components/b-roll-window/b-roll-window.tsx",
|
|
179
|
-
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, placeholderImage, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type BRollWindowProps = {\n /** The main narrative, filling the frame. */\n children?: ReactNode;\n /** Supporting footage. A string is an image URL. */\n media?: string | ReactNode;\n /** Which corner the window sits in. */\n corner?: \"bottom-right\" | \"bottom-left\" | \"top-right\" | \"top-left\";\n /** Frame it enters and frame it leaves. */\n at?: number;\n until?: number;\n label?: string;\n};\n\n/** Which two edges each corner hugs, so the offsets below stay declarative. */\nconst CORNERS: Record<string, {top?: boolean; bottom?: boolean; left?: boolean; right?: boolean}> = {\n \"bottom-right\": {bottom: true, right: true},\n \"bottom-left\": {bottom: true, left: true},\n \"top-right\": {top: true, right: true},\n \"top-left\": {top: true, left: true},\n};\n\n/**\n * Supporting footage beside the main narrative, in and out on cue.\n *\n * The window enters from the edge it sits against, which is what makes it read\n * as arriving from off-screen rather than fading up out of nowhere — and it\n * leaves the same way, so it never has to be cut around.\n */\nexport const BRollWindow = ({\n children,\n media,\n corner = \"bottom-right\",\n at = 24,\n until = 140,\n label,\n}: BRollWindowProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const place = CORNERS[corner];\n const fromRight = corner.endsWith(\"right\");\n\n const enter = interpolate(frame, [at, at + 14], [0, 1], {easing: Easing.standard});\n const leave = interpolate(frame, [until, until + 14], [1, 0], {easing: Easing.standard});\n const visible = Math.min(enter, leave);\n const source = media ?? placeholderImage({label: \"b-roll\", seed: \"broll\", width: 800, height: 500});\n\n return (\n <Fill>\n {children}\n {visible > 0.001 ? (\n <div\n style={{\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n bottom: place.bottom ? 64 * scale : undefined,\n boxShadow: `0 ${24 * scale}px ${60 * scale}px
|
|
133
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, placeholderImage, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type BRollWindowProps = {\n /** The main narrative, filling the frame. */\n children?: ReactNode;\n /** Supporting footage. A string is an image URL. */\n media?: string | ReactNode;\n /** Which corner the window sits in. */\n corner?: \"bottom-right\" | \"bottom-left\" | \"top-right\" | \"top-left\";\n /** Frame it enters and frame it leaves. */\n at?: number;\n until?: number;\n label?: string;\n};\n\n/** Which two edges each corner hugs, so the offsets below stay declarative. */\nconst CORNERS: Record<string, {top?: boolean; bottom?: boolean; left?: boolean; right?: boolean}> = {\n \"bottom-right\": {bottom: true, right: true},\n \"bottom-left\": {bottom: true, left: true},\n \"top-right\": {top: true, right: true},\n \"top-left\": {top: true, left: true},\n};\n\n/**\n * Supporting footage beside the main narrative, in and out on cue.\n *\n * The window enters from the edge it sits against, which is what makes it read\n * as arriving from off-screen rather than fading up out of nowhere — and it\n * leaves the same way, so it never has to be cut around.\n */\nexport const BRollWindow = ({\n children,\n media,\n corner = \"bottom-right\",\n at = 24,\n until = 140,\n label,\n}: BRollWindowProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const place = CORNERS[corner];\n const fromRight = corner.endsWith(\"right\");\n\n const enter = interpolate(frame, [at, at + 14], [0, 1], {easing: Easing.standard});\n const leave = interpolate(frame, [until, until + 14], [1, 0], {easing: Easing.standard});\n const visible = Math.min(enter, leave);\n const source = media ?? placeholderImage({label: \"b-roll\", seed: \"broll\", width: 800, height: 500});\n\n return (\n <Fill>\n {children}\n {visible > 0.001 ? (\n <div\n style={{\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n bottom: place.bottom ? 64 * scale : undefined,\n boxShadow: `0 ${24 * scale}px ${60 * scale}px ${brand.colors.shadow}`,\n height: 340 * scale,\n left: place.left ? 64 * scale : undefined,\n opacity: visible,\n overflow: \"hidden\",\n position: \"absolute\",\n right: place.right ? 64 * scale : undefined,\n top: place.top ? 64 * scale : undefined,\n transform: `translateX(${(1 - visible) * (fromRight ? 60 : -60) * scale}px)`,\n width: 560 * scale,\n }}\n >\n {typeof source === \"string\" ? (\n <img src={source} alt=\"\" style={{height: \"100%\", objectFit: \"cover\", width: \"100%\"}} />\n ) : (\n source\n )}\n {label ? (\n <span\n style={{\n background: \"rgba(0,0,0,0.6)\",\n bottom: 0,\n color: brand.colors.foreground,\n fontFamily: brand.typography.mono,\n fontSize: 20 * scale,\n left: 0,\n padding: `${10 * scale}px ${16 * scale}px`,\n position: \"absolute\",\n right: 0,\n }}\n >\n {label}\n </span>\n ) : null}\n </div>\n ) : null}\n </Fill>\n );\n};\n",
|
|
180
134
|
"target": "videos/components/b-roll-window/b-roll-window.tsx"
|
|
181
135
|
},
|
|
182
136
|
{
|
|
183
137
|
"path": "components/b-roll-window/b-roll-window.preview.tsx",
|
|
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:
|
|
138
|
+
"content": "import {useBrand} from \"odori\";\nimport {defineComponentPreview} from \"odori/preview\";\nimport {BRollWindow} from \"./b-roll-window\";\n\nconst Main = () => {\n const brand = useBrand();\n return (\n <div\n style={{\n alignItems: \"center\",\n background: brand.colors.background,\n color: brand.colors.foreground,\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};\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",
|
|
185
139
|
"target": "videos/components/b-roll-window/b-roll-window.preview.tsx"
|
|
186
140
|
}
|
|
187
141
|
],
|
|
@@ -256,6 +210,48 @@
|
|
|
256
210
|
}
|
|
257
211
|
}
|
|
258
212
|
},
|
|
213
|
+
{
|
|
214
|
+
"name": "bead-field",
|
|
215
|
+
"description": "A fixed grid of dots that swell as two drifting ripples cross them.",
|
|
216
|
+
"registryDependencies": [],
|
|
217
|
+
"files": [
|
|
218
|
+
{
|
|
219
|
+
"path": "components/bead-field/bead-field.tsx",
|
|
220
|
+
"content": "import {Fill, useBrand, useDesignScale, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * A field of beads, sized by the ripples crossing it.\n *\n * Nothing here moves. The dots sit on a fixed pitch and never leave it; what\n * changes is how big each one is, and the size comes from two ripple sources\n * drifting across the frame on periods that do not divide into each other.\n * A grid where the dots travel reads as particles, which is a different and\n * busier idea; a grid where they only swell reads as a surface responding to\n * something, which is what a background is for.\n *\n * The pitch is a design length, so the beads stay the same apparent size in a\n * vertical cut as in a wide one.\n */\nexport type BeadFieldProps = {\n /** Bead and ground. Defaults to the brand's foreground on its background. */\n colors?: [string, string];\n /** Distance between bead centres, in design pixels. */\n spacing?: number;\n /** How much the ripples change a bead's size, 0 to 1. */\n amplitude?: number;\n /** How fast the ripples cross. */\n speed?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst beads = defineShaderEffect({\n name: \"bead-field\",\n uniforms: {\n spacing: numberUniform(46),\n amplitude: numberUniform(0.6),\n speed: numberUniform(1),\n ink: vec3Uniform([1, 1, 1]),\n ground: vec3Uniform([0, 0, 0]),\n },\n fragmentShader: `\nuniform float spacing;\nuniform float amplitude;\nuniform float speed;\nuniform vec3 ink;\nuniform vec3 ground;\n\nvoid main() {\n float pitch = max(6.0, spacing);\n vec2 id = floor(gl_FragCoord.xy / pitch);\n vec2 local = (fract(gl_FragCoord.xy / pitch) - 0.5) * pitch;\n\n /* Every bead is asked about the ripple at its own centre, not at the pixel\n being drawn, so a single bead is one size all over rather than a smear of\n the field it sits in. */\n vec2 centre = (id + 0.5) * pitch / resolution;\n float aspect = resolution.x / resolution.y;\n float t = seconds * speed;\n\n vec2 first = vec2(0.5 + 0.36 * sin(t * 0.37), 0.5 + 0.28 * cos(t * 0.29));\n vec2 second = vec2(0.5 + 0.42 * cos(t * 0.23), 0.5 + 0.31 * sin(t * 0.41));\n float toFirst = length((centre - first) * vec2(aspect, 1.0));\n float toSecond = length((centre - second) * vec2(aspect, 1.0));\n\n /* Two trains, and the ripple decays with distance from its source, which is\n what keeps the far corners quiet instead of uniformly busy. */\n float wave =\n sin(toFirst * 26.0 - t * 2.2) * exp(-toFirst * 1.1) * 0.62 +\n sin(toSecond * 19.0 - t * 1.7) * exp(-toSecond * 0.9) * 0.44;\n\n float radius = pitch * (0.10 + amplitude * 0.26 * clamp(wave * 0.5 + 0.5, 0.0, 1.0));\n float mask = 1.0 - smoothstep(radius - 1.2, radius + 1.2, length(local));\n\n odoriColour = vec4(mix(ground, ink, mask * 0.9), 1.0);\n}`,\n});\n\nexport const BeadField = ({colors, spacing = 46, amplitude = 0.6, speed = 1}: BeadFieldProps) => {\n const brand = useBrand();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n const [ink, ground] = colors ?? [brand.colors.foreground, brand.colors.background];\n\n return (\n <EffectSurface\n effects={[\n beads({\n spacing: Math.max(6, spacing * scale),\n amplitude,\n speed,\n ink: hexToRgb(ink),\n ground: hexToRgb(ground),\n }),\n ]}\n >\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n",
|
|
221
|
+
"target": "videos/components/bead-field/bead-field.tsx"
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
"path": "components/bead-field/bead-field.preview.tsx",
|
|
225
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {BeadField} from \"./bead-field\";\n\nexport default defineComponentPreview({\n title: \"Bead field\",\n category: \"Backgrounds\",\n description: \"A fixed grid of dots that swell as two drifting ripples cross them.\",\n component: BeadField,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n spacing: {type: \"number\", defaultValue: 46, min: 12, max: 120, step: 2},\n amplitude: {type: \"number\", defaultValue: 0.6, min: 0, max: 1, step: 0.05},\n speed: {type: \"number\", defaultValue: 1, min: 0, max: 3, step: 0.1},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Dense\", props: {spacing: 22, amplitude: 0.75}},\n {name: \"Slow tide\", props: {spacing: 70, speed: 0.4, amplitude: 0.9}},\n ],\n});\n",
|
|
226
|
+
"target": "videos/components/bead-field/bead-field.preview.tsx"
|
|
227
|
+
}
|
|
228
|
+
],
|
|
229
|
+
"meta": {
|
|
230
|
+
"kind": "component",
|
|
231
|
+
"family": "Backgrounds",
|
|
232
|
+
"namespaced": "@odori/bead-field",
|
|
233
|
+
"contract": {
|
|
234
|
+
"aspectRatios": [
|
|
235
|
+
"16:9",
|
|
236
|
+
"9:16",
|
|
237
|
+
"1:1"
|
|
238
|
+
],
|
|
239
|
+
"recommendedDurationInFrames": 240,
|
|
240
|
+
"minimumDurationInFrames": 60,
|
|
241
|
+
"entranceFrames": 0,
|
|
242
|
+
"exitFrames": 0,
|
|
243
|
+
"contentLimits": {},
|
|
244
|
+
"reducedMotion": "set speed to 0 and the beads hold one size",
|
|
245
|
+
"requires": {
|
|
246
|
+
"fonts": [],
|
|
247
|
+
"audio": [],
|
|
248
|
+
"graphics": [
|
|
249
|
+
"webgl2"
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
259
255
|
{
|
|
260
256
|
"name": "bed-drift",
|
|
261
257
|
"description": "A pad that breathes instead of keeping time, for narration to pace.",
|
|
@@ -672,7 +668,7 @@
|
|
|
672
668
|
"files": [
|
|
673
669
|
{
|
|
674
670
|
"path": "components/browser-demo/browser-demo.tsx",
|
|
675
|
-
"content": "import type {ReactNode} from \"react\";\nimport {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type BrowserDemoProps = {\n url: string;\n children?: ReactNode;\n /** Frame-driven cursor path in canvas coordinates. */\n cursor?: Array<{frame: number; x: number; y: number}>;\n};\n\n/**\n * A browser chrome frame for product UI. Application components can be passed\n * as children when they render deterministically from frozen props.\n */\nexport const BrowserDemo = ({url, children, cursor = []}: BrowserDemoProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const appear = interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard});\n\n const position =\n cursor.length >= 2\n ? {\n x: interpolate(frame, cursor.map((point) => point.frame), cursor.map((point) => point.x)),\n y: interpolate(frame, cursor.map((point) => point.frame), cursor.map((point) => point.y)),\n }\n : null;\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", padding: 100 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 20 * scale,\n boxShadow: `0 ${40 * scale}px ${80 * scale}px
|
|
671
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type BrowserDemoProps = {\n url: string;\n children?: ReactNode;\n /** Frame-driven cursor path in canvas coordinates. */\n cursor?: Array<{frame: number; x: number; y: number}>;\n};\n\n/**\n * A browser chrome frame for product UI. Application components can be passed\n * as children when they render deterministically from frozen props.\n */\nexport const BrowserDemo = ({url, children, cursor = []}: BrowserDemoProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const appear = interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard});\n\n const position =\n cursor.length >= 2\n ? {\n x: interpolate(frame, cursor.map((point) => point.frame), cursor.map((point) => point.x)),\n y: interpolate(frame, cursor.map((point) => point.frame), cursor.map((point) => point.y)),\n }\n : null;\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", padding: 100 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 20 * scale,\n boxShadow: `0 ${40 * scale}px ${80 * scale}px ${brand.colors.shadow}`,\n height: \"100%\",\n maxHeight: 860 * scale,\n maxWidth: 1500 * scale,\n opacity: appear,\n overflow: \"hidden\",\n position: \"relative\",\n transform: `translateY(${(1 - appear) * 30 * scale}px) scale(${0.98 + appear * 0.02})`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n borderBottom: `1px solid ${brand.colors.border}`,\n background: \"rgba(255,255,255,0.02)\",\n display: \"flex\",\n gap: 9 * scale,\n padding: `${16 * scale}px ${22 * scale}px`,\n }}\n >\n {[0, 1, 2].map((index) => (\n <span key={index} style={{background: brand.colors.surface, borderRadius: 999, height: 12 * scale, width: 12 * scale}} />\n ))}\n <div\n style={{\n background: brand.colors.background,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 999,\n color: brand.colors.muted,\n flex: 1,\n fontFamily: brand.typography.mono,\n fontSize: 20 * scale,\n marginLeft: 12 * scale,\n padding: `${8 * scale}px ${18 * scale}px`,\n textAlign: \"center\",\n }}\n >\n {url}\n </div>\n </div>\n <div style={{flex: 1, position: \"relative\"}}>{children}</div>\n {position ? (\n <div\n style={{\n borderBottom: `${14 * scale}px solid ${brand.colors.foreground}`,\n borderLeft: `${9 * scale}px solid transparent`,\n borderRight: `${9 * scale}px solid transparent`,\n height: 0,\n left: position.x * scale,\n position: \"absolute\",\n top: position.y * scale,\n transform: \"rotate(-30deg)\",\n width: 0,\n }}\n />\n ) : null}\n </div>\n </Fill>\n );\n};\n",
|
|
676
672
|
"target": "videos/components/browser-demo/browser-demo.tsx"
|
|
677
673
|
},
|
|
678
674
|
{
|
|
@@ -719,7 +715,7 @@
|
|
|
719
715
|
},
|
|
720
716
|
{
|
|
721
717
|
"path": "components/camera-stage/camera-stage.preview.tsx",
|
|
722
|
-
"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
|
|
718
|
+
"content": "import {useBrand} from \"odori\";\nimport {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 *\n * Everything is drawn from the brand's own tokens: the stage paints the brand\n * ground, so hard-coded light-world ink left the headline invisible on a dark\n * brand and the card glaring on it.\n */\nconst Travelling = ({depthOfField}: {depthOfField?: number}) => {\n const brand = useBrand();\n return (\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\n style={{\n color: brand.colors.foreground,\n fontSize: 108,\n fontWeight: 400,\n letterSpacing: \"-0.02em\",\n lineHeight: 1.05,\n }}\n >\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: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 28,\n display: \"grid\",\n gap: 18,\n height: \"100%\",\n padding: 40,\n }}\n >\n <div style={{background: brand.colors.border, borderRadius: 12, height: 46, width: \"42%\"}} />\n <div style={{background: brand.colors.border, borderRadius: 12, height: 46, width: \"78%\"}} />\n <div style={{background: brand.colors.border, borderRadius: 12, height: 46, width: \"60%\"}} />\n <div\n style={{background: brand.colors.foreground, borderRadius: 14, height: 58, marginTop: 20, width: 220}}\n />\n </div>\n </CameraItem>\n </CameraStage>\n );\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",
|
|
723
719
|
"target": "videos/components/camera-stage/camera-stage.preview.tsx"
|
|
724
720
|
}
|
|
725
721
|
],
|
|
@@ -756,12 +752,12 @@
|
|
|
756
752
|
"files": [
|
|
757
753
|
{
|
|
758
754
|
"path": "components/canvas-magnifier/canvas-magnifier.tsx",
|
|
759
|
-
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, cursorAt, interpolate, useBrand, useFrame, useDesignScale, type CursorStop} from \"odori\";\n\nexport type CanvasMagnifierProps = {\n /** What is being magnified. Rendered once, at full size, underneath. */\n children?: ReactNode;\n /** Where the lens sits, in composition pixels. Accepts an authored path. */\n path?: CursorStop[];\n /** Static position, when the lens does not move. */\n x?: number;\n y?: number;\n /** Lens diameter in composition pixels. */\n size?: number;\n /** How much larger the detail appears. */\n zoom?: number;\n /** Frames the lens takes to open. */\n entranceFrames?: number;\n};\n\n/**\n * A lens that shows the content underneath it, larger.\n *\n * True enlargement, not a copy: the same subtree is rendered a second time\n * inside a clipped circle, scaled about the lens centre, so what the lens\n * shows is the real thing and cannot drift from it. A screenshot pasted into a\n * circle would be a second asset to keep in step, and it would be wrong the\n * first time the content changed.\n *\n * The cost is that children render twice per frame. That is the honest price\n * of the detail being real, and it is why the lens takes a subtree rather than\n * the whole scene.\n */\nexport const CanvasMagnifier = ({\n children,\n path,\n x = 960,\n y = 540,\n size = 460,\n zoom = 2.4,\n entranceFrames = 14,\n}: CanvasMagnifierProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const cursor = path && path.length > 0 ? cursorAt(path, frame) : null;\n const centreX = cursor ? cursor.x : x;\n const centreY = cursor ? cursor.y : y;\n const open = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard}) * (cursor?.visible ?? 1);\n\n const diameter = size * scale;\n const left = centreX * scale - diameter / 2;\n const top = centreY * scale - diameter / 2;\n\n return (\n <Fill>\n {children}\n <div\n aria-hidden\n style={{\n border: `${Math.max(1, 3 * scale)}px solid ${brand.colors.border}`,\n borderRadius: \"50%\",\n boxShadow: `0 ${20 * scale}px ${50 * scale}px
|
|
755
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, cursorAt, interpolate, useBrand, useFrame, useDesignScale, type CursorStop} from \"odori\";\n\nexport type CanvasMagnifierProps = {\n /** What is being magnified. Rendered once, at full size, underneath. */\n children?: ReactNode;\n /** Where the lens sits, in composition pixels. Accepts an authored path. */\n path?: CursorStop[];\n /** Static position, when the lens does not move. */\n x?: number;\n y?: number;\n /** Lens diameter in composition pixels. */\n size?: number;\n /** How much larger the detail appears. */\n zoom?: number;\n /** Frames the lens takes to open. */\n entranceFrames?: number;\n};\n\n/**\n * A lens that shows the content underneath it, larger.\n *\n * True enlargement, not a copy: the same subtree is rendered a second time\n * inside a clipped circle, scaled about the lens centre, so what the lens\n * shows is the real thing and cannot drift from it. A screenshot pasted into a\n * circle would be a second asset to keep in step, and it would be wrong the\n * first time the content changed.\n *\n * The cost is that children render twice per frame. That is the honest price\n * of the detail being real, and it is why the lens takes a subtree rather than\n * the whole scene.\n */\nexport const CanvasMagnifier = ({\n children,\n path,\n x = 960,\n y = 540,\n size = 460,\n zoom = 2.4,\n entranceFrames = 14,\n}: CanvasMagnifierProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const cursor = path && path.length > 0 ? cursorAt(path, frame) : null;\n const centreX = cursor ? cursor.x : x;\n const centreY = cursor ? cursor.y : y;\n const open = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard}) * (cursor?.visible ?? 1);\n\n const diameter = size * scale;\n const left = centreX * scale - diameter / 2;\n const top = centreY * scale - diameter / 2;\n\n return (\n <Fill>\n {children}\n <div\n aria-hidden\n style={{\n border: `${Math.max(1, 3 * scale)}px solid ${brand.colors.border}`,\n borderRadius: \"50%\",\n boxShadow: `0 ${20 * scale}px ${50 * scale}px ${brand.colors.shadow}`,\n height: diameter,\n left,\n opacity: open,\n overflow: \"hidden\",\n position: \"absolute\",\n top,\n transform: `scale(${0.9 + open * 0.1})`,\n width: diameter,\n }}\n >\n {/* The same subtree, scaled about the point under the lens, so the\n detail is the content rather than a picture of it. */}\n <div\n style={{\n height: \"100%\",\n left: 0,\n position: \"absolute\",\n top: 0,\n transform: `scale(${zoom}) translate(${(diameter / 2 - centreX * scale) / zoom}px, ${\n (diameter / 2 - centreY * scale) / zoom\n }px)`,\n transformOrigin: \"top left\",\n width: \"100%\",\n }}\n >\n {children}\n </div>\n </div>\n </Fill>\n );\n};\n",
|
|
760
756
|
"target": "videos/components/canvas-magnifier/canvas-magnifier.tsx"
|
|
761
757
|
},
|
|
762
758
|
{
|
|
763
759
|
"path": "components/canvas-magnifier/canvas-magnifier.preview.tsx",
|
|
764
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CanvasMagnifier} from \"./canvas-magnifier\";\n\nconst Detail = () => (\n <div style={{background:
|
|
760
|
+
"content": "import {useBrand} from \"odori\";\nimport {defineComponentPreview} from \"odori/preview\";\nimport {CanvasMagnifier} from \"./canvas-magnifier\";\n\nconst Detail = () => {\n const brand = useBrand();\n return (\n <div style={{background: brand.colors.background, 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};\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",
|
|
765
761
|
"target": "videos/components/canvas-magnifier/canvas-magnifier.preview.tsx"
|
|
766
762
|
}
|
|
767
763
|
],
|
|
@@ -872,6 +868,49 @@
|
|
|
872
868
|
}
|
|
873
869
|
}
|
|
874
870
|
},
|
|
871
|
+
{
|
|
872
|
+
"name": "card-deck",
|
|
873
|
+
"description": "A stacked deck where the front card slides off and tucks behind on a frame schedule.",
|
|
874
|
+
"registryDependencies": [],
|
|
875
|
+
"files": [
|
|
876
|
+
{
|
|
877
|
+
"path": "components/card-deck/card-deck.tsx",
|
|
878
|
+
"content": "import {Children, type ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type CardDeckProps = {\n /** The cards, front first. Three to five reads best. */\n children?: ReactNode;\n /** Frames the front card holds before it yields. */\n dwellFrames?: number;\n /** Frames the swap takes. */\n swapFrames?: number;\n /** Vertical spread between stacked cards, in design pixels. */\n offset?: number;\n};\n\n/**\n * A small stack of cards taking turns in front.\n *\n * The stack is the point: seeing the edges of what is coming makes a rotation\n * feel like a deck being worked through rather than a slideshow. On the\n * schedule, the front card arcs out to the side and settles at the back in one\n * motion while everything behind it steps forward — the swap is a single\n * gesture, so the eye follows one card instead of parsing five moves.\n *\n * Depths are fractional during the swap. Each card interpolates from its slot\n * toward the next, which is what keeps the promotion continuous across the\n * cycle boundary instead of snapping when the count rolls over.\n */\nexport const CardDeck = ({children, dwellFrames = 50, swapFrames = 18, offset = 16}: CardDeckProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const supplied = Children.toArray(children);\n const cards =\n supplied.length > 0\n ? supplied\n : [\"Cut our build times in half.\", \"The docs are the product.\", \"We migrated in an afternoon.\"].map((quote) => (\n <div\n key={quote}\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 26 * scale,\n color: brand.colors.foreground,\n display: \"grid\",\n fontSize: 40 * scale,\n fontWeight: 540,\n letterSpacing: \"-0.02em\",\n minHeight: 300 * scale,\n padding: 48 * scale,\n placeContent: \"center\",\n textAlign: \"center\",\n width: 900 * scale,\n }}\n >\n “{quote}”\n </div>\n ));\n\n const count = cards.length;\n const cycle = dwellFrames + swapFrames;\n const round = Math.floor(frame / cycle);\n const swap = count < 2 ? 0 : interpolate(frame % cycle, [dwellFrames, cycle], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\"}}>\n <div style={{display: \"grid\"}}>\n {cards.map((card, index) => {\n const slot = (((index - round) % count) + count) % count;\n // Where this card sits, front at 0, drifting toward its next slot.\n const depth = slot === 0 ? swap * (count - 1) : slot - swap;\n const aside = slot === 0 ? Math.sin(swap * Math.PI) * 480 * scale : 0;\n const layer = slot === 0 ? (swap < 0.5 ? count + 1 : 0) : count - slot;\n return (\n <div\n key={index}\n style={{\n filter: `brightness(${1 - depth * 0.05})`,\n gridArea: \"1 / 1\",\n opacity: 1 - depth * 0.07,\n transform: `translate(${aside}px, ${-depth * offset * scale}px) scale(${1 - depth * 0.06})`,\n transformOrigin: \"center top\",\n zIndex: layer,\n }}\n >\n {card}\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
|
|
879
|
+
"target": "videos/components/card-deck/card-deck.tsx"
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
"path": "components/card-deck/card-deck.preview.tsx",
|
|
883
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {CardDeck} from \"./card-deck\";\n\nconst Feature = ({title, body, hue}: {title: string; body: string; hue: string}) => (\n <div\n style={{\n background: `linear-gradient(160deg, ${hue}, #0b1120)`,\n borderRadius: 26,\n color: \"#f5f7fb\",\n display: \"grid\",\n fontFamily: \"ui-sans-serif, system-ui\",\n gap: 16,\n minHeight: 320,\n padding: 48,\n placeContent: \"center\",\n textAlign: \"center\",\n width: 880,\n }}\n >\n <strong style={{fontSize: 46, letterSpacing: \"-0.03em\"}}>{title}</strong>\n <span style={{fontSize: 28, opacity: 0.7}}>{body}</span>\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Card deck\",\n category: \"Interface/Surfaces\",\n description: \"A stacked deck where the front card slides off and tucks behind on a frame schedule.\",\n component: CardDeck,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n dwellFrames: {type: \"number\", defaultValue: 50, min: 15, max: 150},\n swapFrames: {type: \"number\", defaultValue: 18, min: 8, max: 45},\n offset: {type: \"number\", defaultValue: 16, min: 6, max: 40},\n },\n examples: [\n {name: \"Testimonials\", props: {}},\n {\n name: \"Features\",\n props: {\n dwellFrames: 35,\n children: [\n <Feature key=\"a\" title=\"Branch previews\" body=\"Every push gets a URL\" hue=\"#233a63\" />,\n <Feature key=\"b\" title=\"Instant rollback\" body=\"One click, ten seconds\" hue=\"#2c2354\" />,\n <Feature key=\"c\" title=\"Edge caching\" body=\"Close to every visitor\" hue=\"#14324a\" />,\n <Feature key=\"d\" title=\"Zero config\" body=\"It reads your framework\" hue=\"#31283e\" />,\n ],\n },\n },\n ],\n});\n",
|
|
884
|
+
"target": "videos/components/card-deck/card-deck.preview.tsx"
|
|
885
|
+
}
|
|
886
|
+
],
|
|
887
|
+
"meta": {
|
|
888
|
+
"kind": "component",
|
|
889
|
+
"family": "Interface",
|
|
890
|
+
"namespaced": "@odori/card-deck",
|
|
891
|
+
"contract": {
|
|
892
|
+
"aspectRatios": [
|
|
893
|
+
"16:9",
|
|
894
|
+
"9:16",
|
|
895
|
+
"1:1"
|
|
896
|
+
],
|
|
897
|
+
"recommendedDurationInFrames": 240,
|
|
898
|
+
"minimumDurationInFrames": 68,
|
|
899
|
+
"entranceFrames": 0,
|
|
900
|
+
"exitFrames": 0,
|
|
901
|
+
"contentLimits": {
|
|
902
|
+
"cards": 5
|
|
903
|
+
},
|
|
904
|
+
"reducedMotion": "the deck holds with the first card in front",
|
|
905
|
+
"requires": {
|
|
906
|
+
"fonts": [
|
|
907
|
+
"sans"
|
|
908
|
+
],
|
|
909
|
+
"audio": []
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
},
|
|
875
914
|
{
|
|
876
915
|
"name": "carousel",
|
|
877
916
|
"description": "A paced media sequence with stable aspect handling.",
|
|
@@ -956,6 +995,45 @@
|
|
|
956
995
|
}
|
|
957
996
|
}
|
|
958
997
|
},
|
|
998
|
+
{
|
|
999
|
+
"name": "charged-border",
|
|
1000
|
+
"description": "An energy pass circling a rounded frame, with an optional flare for an emphasis beat.",
|
|
1001
|
+
"registryDependencies": [],
|
|
1002
|
+
"files": [
|
|
1003
|
+
{
|
|
1004
|
+
"path": "components/charged-border/charged-border.tsx",
|
|
1005
|
+
"content": "import type {CSSProperties, ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\nexport type ChargedBorderProps = {\n /** What sits inside the frame. */\n children?: ReactNode;\n /** Colour of the pass. Defaults to the brand accent. */\n color?: string;\n /** Border thickness in design pixels. */\n width?: number;\n /** Corner radius in design pixels. */\n radius?: number;\n /** Revolutions per second. */\n speed?: number;\n /** Frame the flare peaks on, for landing an emphasis beat. */\n at?: number;\n /** Frames the flare takes to swell and again to settle. */\n flareFrames?: number;\n};\n\n/**\n * An energy pass circling a frame.\n *\n * The whole ring is one conic gradient — dim border colour most of the way\n * round, a hot arc at the end — masked to the border band, so the pass follows\n * the corners instead of clipping through them the way a translated highlight\n * would. Its angle is a function of the frame number, never of wall time, so a\n * scrub and a render agree about where the pass is.\n *\n * `at` is for emphasis: the ring flares around that frame and settles again,\n * which lets the border land a beat instead of only idling.\n */\nexport const ChargedBorder = ({\n children,\n color,\n width = 3,\n radius = 20,\n speed = 0.4,\n at,\n flareFrames = 18,\n}: ChargedBorderProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {fps} = useVideo();\n\n const glow = color ?? brand.colors.accent;\n const band = Math.max(1, width * scale);\n const corner = radius * scale;\n const angle = ((frame / fps) * speed * 360) % 360;\n const flare =\n at === undefined\n ? 0\n : interpolate(frame, [at - flareFrames, at, at + flareFrames], [0, 1, 0], {easing: Easing.standard});\n\n /* The conic layer, masked down to the border band: content-box knocked out\n of the full box, which leaves exactly the ring the padding describes. */\n const ring: CSSProperties = {\n background: `conic-gradient(from ${angle}deg, ${brand.colors.border} 0deg, ${brand.colors.border} 230deg, ${glow} 320deg, ${glow} 348deg, ${brand.colors.border} 360deg)`,\n borderRadius: corner,\n inset: 0,\n mask: \"linear-gradient(#fff 0 0) content-box exclude, linear-gradient(#fff 0 0)\",\n padding: band,\n position: \"absolute\",\n WebkitMask: \"linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)\",\n WebkitMaskComposite: \"xor\",\n };\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\"}}>\n <div style={{borderRadius: corner, maxHeight: \"100%\", maxWidth: \"100%\", padding: band, position: \"relative\"}}>\n {/* The same ring blurred, which is all a glow is. The flare widens and\n brightens it, then hands back to the idle pass. */}\n <div aria-hidden style={{...ring, filter: `blur(${band * (2 + flare * 4)}px)`, opacity: 0.5 + flare * 0.5}} />\n <div aria-hidden style={ring} />\n <div style={{borderRadius: Math.max(0, corner - band), overflow: \"hidden\", position: \"relative\"}}>\n {children}\n </div>\n </div>\n </Fill>\n );\n};\n",
|
|
1006
|
+
"target": "videos/components/charged-border/charged-border.tsx"
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
"path": "components/charged-border/charged-border.preview.tsx",
|
|
1010
|
+
"content": "import {useBrand} from \"odori\";\nimport {defineComponentPreview} from \"odori/preview\";\nimport {ChargedBorder} from \"./charged-border\";\n\nconst Card = () => {\n const brand = useBrand();\n return (\n <div\n style={{\n alignItems: \"center\",\n background: brand.colors.surface,\n color: brand.colors.foreground,\n display: \"flex\",\n fontFamily: brand.typography.sans,\n fontSize: 64,\n fontWeight: 600,\n height: 420,\n justifyContent: \"center\",\n letterSpacing: \"-0.03em\",\n width: 900,\n }}\n >\n Now shipping\n </div>\n );\n};\n\nexport default defineComponentPreview({\n title: \"Charged border\",\n category: \"Motion\",\n description: \"An energy pass circling a rounded frame, with an optional flare for an emphasis beat.\",\n component: ChargedBorder,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n width: {type: \"number\", defaultValue: 3, min: 1, max: 12, step: 1},\n radius: {type: \"number\", defaultValue: 20, min: 0, max: 80, step: 2},\n speed: {type: \"number\", defaultValue: 0.4, min: 0.05, max: 2, step: 0.05},\n at: {type: \"number\", defaultValue: 60, min: 0, max: 170},\n flareFrames: {type: \"number\", defaultValue: 18, min: 4, max: 60},\n },\n examples: [\n {name: \"Idle\", props: {children: <Card />}},\n {name: \"Flare on the beat\", props: {children: <Card />, at: 60}},\n {name: \"Thick and fast\", props: {children: <Card />, width: 8, speed: 1.1, color: \"#7dd3fc\"}},\n ],\n});\n",
|
|
1011
|
+
"target": "videos/components/charged-border/charged-border.preview.tsx"
|
|
1012
|
+
}
|
|
1013
|
+
],
|
|
1014
|
+
"meta": {
|
|
1015
|
+
"kind": "component",
|
|
1016
|
+
"family": "Motion",
|
|
1017
|
+
"namespaced": "@odori/charged-border",
|
|
1018
|
+
"contract": {
|
|
1019
|
+
"aspectRatios": [
|
|
1020
|
+
"16:9",
|
|
1021
|
+
"9:16",
|
|
1022
|
+
"1:1"
|
|
1023
|
+
],
|
|
1024
|
+
"recommendedDurationInFrames": 180,
|
|
1025
|
+
"minimumDurationInFrames": 30,
|
|
1026
|
+
"entranceFrames": 0,
|
|
1027
|
+
"exitFrames": 0,
|
|
1028
|
+
"contentLimits": {},
|
|
1029
|
+
"reducedMotion": "set speed to 0 for a still ring; leave at unset so it never flares",
|
|
1030
|
+
"requires": {
|
|
1031
|
+
"fonts": [],
|
|
1032
|
+
"audio": []
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
959
1037
|
{
|
|
960
1038
|
"name": "chatgpt",
|
|
961
1039
|
"description": "A chat composer whose starters clear as the prompt lands and the send control fills.",
|
|
@@ -1091,7 +1169,7 @@
|
|
|
1091
1169
|
"files": [
|
|
1092
1170
|
{
|
|
1093
1171
|
"path": "components/code-proof/code-proof.tsx",
|
|
1094
|
-
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type CodeProofProps = {\n code: string;\n language?: string;\n title?: string;\n /** One-based line numbers to emphasize. Other lines dim. */\n focus?: number[];\n caption?: string;\n};\n\ntype Token = {pattern: RegExp; role: \"comment\" | \"keyword\" | \"string\" | \"number\" | \"type\" | \"punctuation\"};\n\nconst TOKENS: Token[] = [\n {pattern: /(\\/\\/[^\\n]*)/g, role: \"comment\"},\n {pattern: /(\"[^\"]*\"|'[^']*'|`[^`]*`)/g, role: \"string\"},\n {\n pattern: /\\b(import|export|from|const|let|var|return|function|default|await|async|type|interface|new|class)\\b/g,\n role: \"keyword\",\n },\n {pattern: /\\b([A-Z][A-Za-z0-9]*)\\b/g, role: \"type\"},\n {pattern: /\\b(\\d+(?:\\.\\d+)?)\\b/g, role: \"number\"},\n];\n\nconst PALETTE: Record<Token[\"role\"], string> = {\n comment: \"#6f6f6f\",\n keyword: \"#c586ff\",\n string: \"#7ee787\",\n number: \"#ffc86b\",\n type: \"#79c0ff\",\n punctuation: \"#8b8b8b\",\n};\n\nconst highlight = (line: string) => {\n const marks: Array<{start: number; end: number; color: string}> = [];\n for (const token of TOKENS) {\n for (const match of line.matchAll(token.pattern)) {\n const start = match.index ?? 0;\n const end = start + match[0].length;\n if (marks.some((mark) => start < mark.end && end > mark.start)) continue;\n marks.push({start, end, color: PALETTE[token.role]});\n }\n }\n marks.sort((left, right) => left.start - right.start);\n\n const parts: Array<{text: string; color?: string}> = [];\n let cursor = 0;\n for (const mark of marks) {\n if (mark.start > cursor) parts.push({text: line.slice(cursor, mark.start)});\n parts.push({text: line.slice(mark.start, mark.end), color: mark.color});\n cursor = mark.end;\n }\n if (cursor < line.length) parts.push({text: line.slice(cursor)});\n return parts;\n};\n\n/**\n * A source excerpt in a product-grade window. Lines resolve from a small blur\n * so the eye lands on the code rather than on the motion.\n */\nexport const CodeProof = ({code, language = \"tsx\", title, focus = [], caption}: CodeProofProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const lines = code.replace(/\\n+$/, \"\").split(\"\\n\");\n const enter = interpolate(frame, [0, 22], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", gap: 30 * scale, justifyContent: \"center\", padding: 110 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n boxShadow: `0 ${30 * scale}px ${90 * scale}px
|
|
1172
|
+
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type CodeProofProps = {\n code: string;\n language?: string;\n title?: string;\n /** One-based line numbers to emphasize. Other lines dim. */\n focus?: number[];\n caption?: string;\n};\n\ntype Token = {pattern: RegExp; role: \"comment\" | \"keyword\" | \"string\" | \"number\" | \"type\" | \"punctuation\"};\n\nconst TOKENS: Token[] = [\n {pattern: /(\\/\\/[^\\n]*)/g, role: \"comment\"},\n {pattern: /(\"[^\"]*\"|'[^']*'|`[^`]*`)/g, role: \"string\"},\n {\n pattern: /\\b(import|export|from|const|let|var|return|function|default|await|async|type|interface|new|class)\\b/g,\n role: \"keyword\",\n },\n {pattern: /\\b([A-Z][A-Za-z0-9]*)\\b/g, role: \"type\"},\n {pattern: /\\b(\\d+(?:\\.\\d+)?)\\b/g, role: \"number\"},\n];\n\nconst PALETTE: Record<Token[\"role\"], string> = {\n comment: \"#6f6f6f\",\n keyword: \"#c586ff\",\n string: \"#7ee787\",\n number: \"#ffc86b\",\n type: \"#79c0ff\",\n punctuation: \"#8b8b8b\",\n};\n\nconst highlight = (line: string) => {\n const marks: Array<{start: number; end: number; color: string}> = [];\n for (const token of TOKENS) {\n for (const match of line.matchAll(token.pattern)) {\n const start = match.index ?? 0;\n const end = start + match[0].length;\n if (marks.some((mark) => start < mark.end && end > mark.start)) continue;\n marks.push({start, end, color: PALETTE[token.role]});\n }\n }\n marks.sort((left, right) => left.start - right.start);\n\n const parts: Array<{text: string; color?: string}> = [];\n let cursor = 0;\n for (const mark of marks) {\n if (mark.start > cursor) parts.push({text: line.slice(cursor, mark.start)});\n parts.push({text: line.slice(mark.start, mark.end), color: mark.color});\n cursor = mark.end;\n }\n if (cursor < line.length) parts.push({text: line.slice(cursor)});\n return parts;\n};\n\n/**\n * A source excerpt in a product-grade window. Lines resolve from a small blur\n * so the eye lands on the code rather than on the motion.\n */\nexport const CodeProof = ({code, language = \"tsx\", title, focus = [], caption}: CodeProofProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const lines = code.replace(/\\n+$/, \"\").split(\"\\n\");\n const enter = interpolate(frame, [0, 22], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", gap: 30 * scale, justifyContent: \"center\", padding: 110 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n boxShadow: `0 ${30 * scale}px ${90 * scale}px ${brand.colors.shadow}`,\n maxWidth: 1440 * scale,\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * 28 * scale}px)`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n background: \"rgba(255,255,255,0.02)\",\n borderBottom: `1px solid ${brand.colors.border}`,\n display: \"flex\",\n gap: 14 * scale,\n padding: `${18 * scale}px ${24 * scale}px`,\n }}\n >\n <div style={{display: \"flex\", gap: 9 * scale}}>\n {[\"#2a2a2a\", \"#2a2a2a\", \"#2a2a2a\"].map((color, index) => (\n <span key={index} style={{background: color, borderRadius: 999, height: 12 * scale, width: 12 * scale}} />\n ))}\n </div>\n {title ? (\n <span\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 21 * scale,\n letterSpacing: \"-0.01em\",\n marginLeft: 8 * scale,\n }}\n >\n {title}\n </span>\n ) : null}\n <span\n style={{\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 6 * scale,\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 21 * scale,\n marginLeft: \"auto\",\n padding: `${4 * scale}px ${12 * scale}px`,\n textTransform: \"uppercase\",\n }}\n >\n {language}\n </span>\n </div>\n\n <pre\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.mono,\n fontSize: 28 * scale,\n lineHeight: 1.65,\n margin: 0,\n padding: `${30 * scale}px ${28 * scale}px`,\n }}\n >\n {lines.map((line, index) => {\n const delay = 10 + index * 3;\n const appear = interpolate(frame, [delay, delay + 14], [0, 1], {easing: Easing.standard});\n const focused = focus.length === 0 || focus.includes(index + 1);\n return (\n <div\n key={`${line}-${index}`}\n style={{\n display: \"flex\",\n filter: `blur(${(1 - appear) * 6}px)`,\n gap: 24 * scale,\n opacity: appear * (focused ? 1 : 0.32),\n transform: `translateY(${(1 - appear) * 8 * scale}px)`,\n }}\n >\n <span style={{color: \"#3a3a3a\", minWidth: 32 * scale, textAlign: \"right\", userSelect: \"none\"}}>\n {index + 1}\n </span>\n <span style={{whiteSpace: \"pre\"}}>\n {highlight(line).map((part, partIndex) => (\n <span key={partIndex} style={{color: part.color}}>\n {part.text}\n </span>\n ))}\n </span>\n </div>\n );\n })}\n </pre>\n </div>\n\n {caption ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 30 * scale,\n letterSpacing: \"-0.01em\",\n opacity: interpolate(frame, [24, 40], [0, 1], {easing: Easing.standard}),\n }}\n >\n {caption}\n </div>\n ) : null}\n </Fill>\n );\n};\n",
|
|
1095
1173
|
"target": "videos/components/code-proof/code-proof.tsx"
|
|
1096
1174
|
},
|
|
1097
1175
|
{
|
|
@@ -1178,7 +1256,7 @@
|
|
|
1178
1256
|
"files": [
|
|
1179
1257
|
{
|
|
1180
1258
|
"path": "components/combobox/combobox.tsx",
|
|
1181
|
-
"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:
|
|
1259
|
+
"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: brand.colors.background, justifyContent: \"center\", padding: px(120)}}>\n <div style={{fontFamily: brand.typography.sans, maxWidth: px(860), opacity: enter, width: \"100%\"}}>\n <div style={{color: brand.colors.muted, fontSize: px(20), marginBottom: px(12)}}>{label}</div>\n\n <div\n style={{\n background: brand.colors.background,\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: brand.colors.background,\n border: `${px(1)}px solid ${brand.colors.border}`,\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",
|
|
1182
1260
|
"target": "videos/components/combobox/combobox.tsx"
|
|
1183
1261
|
},
|
|
1184
1262
|
{
|
|
@@ -1221,7 +1299,7 @@
|
|
|
1221
1299
|
"files": [
|
|
1222
1300
|
{
|
|
1223
1301
|
"path": "components/command-menu/command-menu.tsx",
|
|
1224
|
-
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type CommandMenuProps = {\n /** What the operator types, revealed character by character. */\n query: string;\n items: string[];\n /** Index the selection lands on. */\n selectedIndex?: number;\n placeholder?: string;\n};\n\n/**\n * A keyboard-first palette: the query types itself, the list narrows to what\n * matches, and the selection settles on one action. Filtering is computed from\n * the typed prefix, so the list on screen is the list the query produces.\n */\nexport const CommandMenu = ({query, items, selectedIndex = 0, placeholder = \"Search actions\"}: CommandMenuProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const open = interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard});\n const typed = query.slice(0, Math.max(0, Math.floor((frame - 14) / 2)));\n const matches = items.filter((item) => item.toLowerCase().includes(typed.toLowerCase()));\n const selectAt = 16 + query.length * 2 + 12;\n const selection = interpolate(frame, [selectAt, selectAt + 12], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", padding: 90 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 26 * scale,\n boxShadow: `0 ${40 * scale}px ${120 * scale}px
|
|
1302
|
+
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type CommandMenuProps = {\n /** What the operator types, revealed character by character. */\n query: string;\n items: string[];\n /** Index the selection lands on. */\n selectedIndex?: number;\n placeholder?: string;\n};\n\n/**\n * A keyboard-first palette: the query types itself, the list narrows to what\n * matches, and the selection settles on one action. Filtering is computed from\n * the typed prefix, so the list on screen is the list the query produces.\n */\nexport const CommandMenu = ({query, items, selectedIndex = 0, placeholder = \"Search actions\"}: CommandMenuProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const open = interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard});\n const typed = query.slice(0, Math.max(0, Math.floor((frame - 14) / 2)));\n const matches = items.filter((item) => item.toLowerCase().includes(typed.toLowerCase()));\n const selectAt = 16 + query.length * 2 + 12;\n const selection = interpolate(frame, [selectAt, selectAt + 12], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", padding: 90 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 26 * scale,\n boxShadow: `0 ${40 * scale}px ${120 * scale}px ${brand.colors.shadow}`,\n opacity: open,\n overflow: \"hidden\",\n transform: `translateY(${(1 - open) * 26 * scale}px) scale(${0.97 + open * 0.03})`,\n width: 1080 * scale,\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n borderBottom: `1px solid ${brand.colors.border}`,\n display: \"flex\",\n gap: 14 * scale,\n padding: `${26 * scale}px ${30 * scale}px`,\n }}\n >\n <span style={{color: brand.colors.muted, fontFamily: brand.typography.mono, fontSize: 32 * scale}}>{\">\"}</span>\n <span style={{fontSize: 36 * scale}}>\n {typed || <span style={{color: brand.colors.muted}}>{placeholder}</span>}\n </span>\n <span\n style={{\n background: brand.colors.accent,\n height: 36 * scale,\n opacity: frame % 30 < 15 ? 1 : 0,\n width: 3 * scale,\n }}\n />\n </div>\n <div style={{padding: `${14 * scale}px ${12 * scale}px`}}>\n {matches.map((item, index) => {\n const active = index === Math.min(selectedIndex, matches.length - 1);\n return (\n <div\n key={item}\n style={{\n background: active\n ? `color-mix(in srgb, ${brand.colors.accent} ${selection * 14}%, transparent)`\n : \"transparent\",\n borderRadius: 14 * scale,\n color: active ? brand.colors.foreground : brand.colors.muted,\n fontSize: 32 * scale,\n padding: `${16 * scale}px ${20 * scale}px`,\n }}\n >\n {item}\n </div>\n );\n })}\n </div>\n </div>\n </Fill>\n );\n};\n",
|
|
1225
1303
|
"target": "videos/components/command-menu/command-menu.tsx"
|
|
1226
1304
|
},
|
|
1227
1305
|
{
|
|
@@ -1397,7 +1475,7 @@
|
|
|
1397
1475
|
"files": [
|
|
1398
1476
|
{
|
|
1399
1477
|
"path": "components/control-stage/control-stage.tsx",
|
|
1400
|
-
"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:
|
|
1478
|
+
"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: brand.colors.foreground, 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",
|
|
1401
1479
|
"target": "videos/components/control-stage/control-stage.tsx"
|
|
1402
1480
|
},
|
|
1403
1481
|
{
|
|
@@ -1436,17 +1514,17 @@
|
|
|
1436
1514
|
},
|
|
1437
1515
|
{
|
|
1438
1516
|
"name": "crt-terminal",
|
|
1439
|
-
"description": "A terminal on a cathode ray tube:
|
|
1517
|
+
"description": "A terminal on a cathode ray tube: curved glass, unconverged beam, phosphor bloom and a rolling shadow mask.",
|
|
1440
1518
|
"registryDependencies": [],
|
|
1441
1519
|
"files": [
|
|
1442
1520
|
{
|
|
1443
1521
|
"path": "components/crt-terminal/crt-terminal.tsx",
|
|
1444
|
-
"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",
|
|
1522
|
+
"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 * How much glass there is, 0 to 1.\n *\n * Bowing the text alone leaves a flat rectangle with bent writing on it,\n * which is not what a tube looks like: the giveaway is the face itself,\n * corners pulled in and edges pushed out. This drives both, so one number\n * takes the screen from a flat panel to an obvious bulb.\n */\n curvature?: 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 curvature = 0.75,\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 curve = Math.min(1, Math.max(0, curvature));\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 + 0.05 * curve);\n const x = left + fromCentre * fromCentre * w * (0.006 + 0.022 * curve);\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 /*\n * The face of the tube, cut last so everything above it is inside the\n * glass rather than beside it.\n *\n * Four quadratics, one per edge, each bulging away from the centre: the\n * corners come in and the sides go out, which is the shape a shadow\n * mask actually is. Cutting it with destination-in means the scanlines,\n * the sweep, and the vignette all stop at the same curve instead of\n * running square off the edge and giving the panel away.\n */\n const inset = Math.min(w, h) * (0.012 + 0.03 * curve);\n const bulgeX = w * 0.055 * curve;\n const bulgeY = h * 0.075 * curve;\n const face = (): void => {\n const x0 = inset;\n const y0 = inset;\n const x1 = w - inset;\n const y1 = h - inset;\n context.beginPath();\n context.moveTo(x0, y0);\n context.quadraticCurveTo(w / 2, y0 - bulgeY, x1, y0);\n context.quadraticCurveTo(x1 + bulgeX, h / 2, x1, y1);\n context.quadraticCurveTo(w / 2, y1 + bulgeY, x0, y1);\n context.quadraticCurveTo(x0 - bulgeX, h / 2, x0, y0);\n context.closePath();\n };\n\n context.globalCompositeOperation = \"destination-in\";\n context.fillStyle = \"#000\";\n face();\n context.fill();\n context.globalCompositeOperation = \"source-over\";\n\n // The rim catches the room it is sitting in.\n face();\n context.strokeStyle = `rgba(255,255,255,${0.05 + 0.05 * curve})`;\n context.lineWidth = Math.max(1, Math.min(w, h) * 0.004);\n context.stroke();\n },\n [history, command, response, prompt, phosphor, intensity, charactersPerSecond, curvature],\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",
|
|
1445
1523
|
"target": "videos/components/crt-terminal/crt-terminal.tsx"
|
|
1446
1524
|
},
|
|
1447
1525
|
{
|
|
1448
1526
|
"path": "components/crt-terminal/crt-terminal.preview.tsx",
|
|
1449
|
-
"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:
|
|
1527
|
+
"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: curved 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 curvature: {type: \"number\", defaultValue: 0.75, min: 0, max: 1, step: 0.05},\n },\n examples: [\n {name: \"P1 green\", props: {}},\n {name: \"Amber\", props: {phosphor: \"amber\", intensity: 0.75}},\n {name: \"Flat panel\", props: {curvature: 0.1, intensity: 0.4}},\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",
|
|
1450
1528
|
"target": "videos/components/crt-terminal/crt-terminal.preview.tsx"
|
|
1451
1529
|
}
|
|
1452
1530
|
],
|
|
@@ -1491,7 +1569,7 @@
|
|
|
1491
1569
|
},
|
|
1492
1570
|
{
|
|
1493
1571
|
"path": "components/cursor-focus/cursor-focus.preview.tsx",
|
|
1494
|
-
"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:
|
|
1572
|
+
"content": "import {useBrand} from \"odori\";\nimport {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 const brand = useBrand();\n return (\n <div style={{background: brand.colors.background, 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: brand.colors.foreground,\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};\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",
|
|
1495
1573
|
"target": "videos/components/cursor-focus/cursor-focus.preview.tsx"
|
|
1496
1574
|
}
|
|
1497
1575
|
],
|
|
@@ -1616,7 +1694,7 @@
|
|
|
1616
1694
|
"files": [
|
|
1617
1695
|
{
|
|
1618
1696
|
"path": "components/date-picker/date-picker.tsx",
|
|
1619
|
-
"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:
|
|
1697
|
+
"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: brand.colors.background, justifyContent: \"center\", padding: px(120)}}>\n <div style={{fontFamily: brand.typography.sans, opacity: enter}}>\n <div style={{color: brand.colors.muted, fontSize: px(20), marginBottom: px(12)}}>{label}</div>\n <div\n style={{\n background: brand.colors.background,\n border: `${px(1)}px solid ${brand.colors.border}`,\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: brand.colors.foreground, 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: brand.colors.muted, 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",
|
|
1620
1698
|
"target": "videos/components/date-picker/date-picker.tsx"
|
|
1621
1699
|
},
|
|
1622
1700
|
{
|
|
@@ -1652,6 +1730,48 @@
|
|
|
1652
1730
|
}
|
|
1653
1731
|
}
|
|
1654
1732
|
},
|
|
1733
|
+
{
|
|
1734
|
+
"name": "deep-swell",
|
|
1735
|
+
"description": "Open water to the horizon: six wave trains crossing, with the sun sitting on the line.",
|
|
1736
|
+
"registryDependencies": [],
|
|
1737
|
+
"files": [
|
|
1738
|
+
{
|
|
1739
|
+
"path": "components/deep-swell/deep-swell.tsx",
|
|
1740
|
+
"content": "import {Fill, useBrand, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * Open water seen from just above it, to the horizon.\n *\n * A water surface is a sum of waves, and the thing that makes one read as\n * water rather than as a ripple pattern is that the sum is uneven: several\n * trains crossing at angles that never repeat inside a shot, each slower and\n * longer than the last. Six of them is enough to lose the beat.\n *\n * The horizon does the rest. Screen height maps to distance rather than to\n * space, so waves compress as they recede and the eye reads depth before it\n * reads motion — and the sun sits on that same line, which is what turns a\n * moving surface into a time of day.\n */\nexport type DeepSwellProps = {\n /** Water and sky. Defaults to the brand's ground and its accent. */\n colors?: [string, string];\n /** Wave height. Past about 0.7 the sea stops being calm. */\n swell?: number;\n /** How fast the trains run. */\n speed?: number;\n /** Where the sun sits along the horizon, 0 left to 1 right. */\n sun?: number;\n};\n\n/**\n * The brand's darkest and lightest tokens, in that order.\n *\n * Water is dark and sky is light in every weather, so a component that took\n * `background` for the sea painted a white ocean under a light brand and a\n * black sky under a dark one. Asking which token is darker rather than which\n * token is named background is what makes one default correct in both.\n */\nconst tones = (background: string, foreground: string): [string, string] => {\n const luminance = (hex: string): number => {\n const [r, g, b] = hexToRgb(hex);\n return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n };\n return luminance(background) <= luminance(foreground)\n ? [background, foreground]\n : [foreground, background];\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst swell = defineShaderEffect({\n name: \"deep-swell\",\n uniforms: {\n swell: numberUniform(0.45),\n speed: numberUniform(1),\n sun: numberUniform(0.5),\n water: vec3Uniform([0.02, 0.05, 0.08]),\n sky: vec3Uniform([0.9, 0.94, 1]),\n },\n fragmentShader: `\nuniform float swell;\nuniform float speed;\nuniform float sun;\nuniform vec3 water;\nuniform vec3 sky;\n\n/* One wave train: a direction, a length, and a speed of its own. Kept as a\n function so the height and the two samples either side of it - which is how\n the normal is found - all agree by construction. */\nfloat train(vec2 p, vec2 dir, float length, float rate, float t) {\n return sin(dot(p, dir) * length + t * rate);\n}\n\nfloat surface(vec2 p, float t) {\n float h = 0.0;\n h += train(p, normalize(vec2(1.0, 0.35)), 2.30, 0.90, t) * 1.00;\n h += train(p, normalize(vec2(-0.7, 0.6)), 3.70, 1.30, t) * 0.55;\n h += train(p, normalize(vec2(0.25, 1.0)), 6.10, 1.70, t) * 0.30;\n h += train(p, normalize(vec2(-1.0, 0.15)), 9.70, 2.30, t) * 0.16;\n h += train(p, normalize(vec2(0.6, -0.8)), 15.3, 3.10, t) * 0.09;\n h += train(p, normalize(vec2(1.0, 1.0)), 24.7, 4.10, t) * 0.05;\n return h / 2.15;\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n float t = seconds * speed;\n\n /* Above the horizon is sky, and the horizon sits high so the water is the\n subject. The gradient is faint on purpose: a sky that competes is a sky\n that dates the shot. */\n float horizon = 0.72;\n if (uv.y > horizon) {\n float up = (uv.y - horizon) / (1.0 - horizon);\n vec3 air = mix(sky * 0.55, sky * 0.24, up);\n float glow = exp(-pow(abs(uv.x - sun) * 3.4, 2.0)) * (1.0 - up) * 0.5;\n odoriColour = vec4(air + sky * glow, 1.0);\n return;\n }\n\n /* Screen height becomes distance. The reciprocal is what compresses the\n waves as they recede; without it the sea reads as wallpaper. */\n float depth = horizon - uv.y;\n float distance = 0.16 / max(depth, 0.004);\n vec2 p = vec2((uv.x - 0.5) * distance * 2.2, distance);\n\n /* The normal, from two samples either side. Cheaper than a derivative and\n stable at the horizon, where screen-space derivatives explode. */\n float e = 0.012 + distance * 0.004;\n float h = surface(p, t);\n float hx = surface(p + vec2(e, 0.0), t);\n float hy = surface(p + vec2(0.0, e), t);\n vec3 normal = normalize(vec3((h - hx) * swell, e * 2.2, (h - hy) * swell));\n\n /* Fresnel: water is a mirror at a glancing angle and glass overhead, which\n is the whole reason a sea is bright at the horizon and dark underfoot. */\n vec3 view = normalize(vec3(0.0, 0.42, -1.0));\n float fresnel = pow(1.0 - clamp(dot(normal, view), 0.0, 1.0), 3.4);\n\n vec3 sunDir = normalize(vec3((sun - 0.5) * 2.0, 0.11, 1.0));\n float specular = pow(max(dot(reflect(-sunDir, normal), view), 0.0), 90.0);\n\n vec3 colour = mix(water, water * 2.4 + sky * 0.10, clamp(h * 0.5 + 0.5, 0.0, 1.0));\n colour = mix(colour, sky * 0.55, fresnel * 0.75);\n colour += sky * specular * 1.5;\n\n /* Haze at the horizon line, so the water meets the sky instead of ending. */\n colour = mix(colour, sky * 0.42, smoothstep(0.55, 1.0, uv.y / horizon) * 0.5);\n odoriColour = vec4(colour, 1.0);\n}`,\n});\n\nexport const DeepSwell = ({colors, swell: height = 0.45, speed = 1, sun = 0.5}: DeepSwellProps) => {\n const brand = useBrand();\n const {width, height: frameHeight} = useVideo();\n const [water, sky] = colors ?? tones(brand.colors.background, brand.colors.foreground);\n\n return (\n <EffectSurface\n effects={[swell({swell: height, speed, sun, water: hexToRgb(water), sky: hexToRgb(sky)})]}\n >\n <Fill style={{background: brand.colors.background, height: frameHeight, width}} />\n </EffectSurface>\n );\n};\n",
|
|
1741
|
+
"target": "videos/components/deep-swell/deep-swell.tsx"
|
|
1742
|
+
},
|
|
1743
|
+
{
|
|
1744
|
+
"path": "components/deep-swell/deep-swell.preview.tsx",
|
|
1745
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DeepSwell} from \"./deep-swell\";\n\nexport default defineComponentPreview({\n title: \"Deep swell\",\n category: \"Backgrounds\",\n description: \"Open water to the horizon: six wave trains crossing, with the sun sitting on the line.\",\n component: DeepSwell,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n swell: {type: \"number\", defaultValue: 0.45, min: 0, max: 1, step: 0.05},\n speed: {type: \"number\", defaultValue: 1, min: 0, max: 3, step: 0.1},\n sun: {type: \"number\", defaultValue: 0.5, min: 0, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Low sun\", props: {sun: 0.18, swell: 0.32, speed: 0.7}},\n {name: \"Heavy sea\", props: {swell: 0.85, speed: 1.6}},\n ],\n});\n",
|
|
1746
|
+
"target": "videos/components/deep-swell/deep-swell.preview.tsx"
|
|
1747
|
+
}
|
|
1748
|
+
],
|
|
1749
|
+
"meta": {
|
|
1750
|
+
"kind": "component",
|
|
1751
|
+
"family": "Backgrounds",
|
|
1752
|
+
"namespaced": "@odori/deep-swell",
|
|
1753
|
+
"contract": {
|
|
1754
|
+
"aspectRatios": [
|
|
1755
|
+
"16:9",
|
|
1756
|
+
"9:16",
|
|
1757
|
+
"1:1"
|
|
1758
|
+
],
|
|
1759
|
+
"recommendedDurationInFrames": 240,
|
|
1760
|
+
"minimumDurationInFrames": 60,
|
|
1761
|
+
"entranceFrames": 0,
|
|
1762
|
+
"exitFrames": 0,
|
|
1763
|
+
"contentLimits": {},
|
|
1764
|
+
"reducedMotion": "set speed to 0 and the sea holds one still surface",
|
|
1765
|
+
"requires": {
|
|
1766
|
+
"fonts": [],
|
|
1767
|
+
"audio": [],
|
|
1768
|
+
"graphics": [
|
|
1769
|
+
"webgl2"
|
|
1770
|
+
]
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
},
|
|
1655
1775
|
{
|
|
1656
1776
|
"name": "dependency-graph",
|
|
1657
1777
|
"description": "Packages and modules connected as a readable graph.",
|
|
@@ -1703,7 +1823,7 @@
|
|
|
1703
1823
|
"files": [
|
|
1704
1824
|
{
|
|
1705
1825
|
"path": "components/device-frame/device-frame.tsx",
|
|
1706
|
-
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type DeviceFrameProps = {\n /** Which body to draw around the content. */\n device?: \"phone\" | \"tablet\" | \"desktop\";\n children?: ReactNode;\n /** Shown in the desktop chrome's address bar. Ignored by the other two. */\n url?: string;\n /** Frames the device takes to settle into place. */\n entranceFrames?: number;\n};\n\nconst BODIES = {\n phone: {width: 480, height: 980, radius: 62, bezel: 14},\n tablet: {width: 900, height: 1180, radius: 40, bezel: 18},\n desktop: {width: 1560, height: 940, radius: 22, bezel: 0},\n} as const;\n\n/**\n * A product stage with a body around it: the same content reads as an app on a\n * phone and as a site on a desktop, and the frame is what tells the viewer\n * which claim is being made.\n *\n * Proportions are the real ones rather than a stylised outline, because a\n * device that is the wrong shape quietly undermines the screenshot inside it.\n * Nothing here is a photograph, so it takes the brand's colours and stays\n * honest at any format.\n */\nexport const DeviceFrame = ({device = \"desktop\", children, url, entranceFrames = 20}: DeviceFrameProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const body = BODIES[device];\n\n const appear = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard});\n const bezel = body.bezel * scale;\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", padding: 60 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `${Math.max(1, 2 * scale)}px solid ${brand.colors.border}`,\n borderRadius: body.radius * scale,\n boxShadow: `0 ${44 * scale}px ${90 * scale}px
|
|
1826
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type DeviceFrameProps = {\n /** Which body to draw around the content. */\n device?: \"phone\" | \"tablet\" | \"desktop\";\n children?: ReactNode;\n /** Shown in the desktop chrome's address bar. Ignored by the other two. */\n url?: string;\n /** Frames the device takes to settle into place. */\n entranceFrames?: number;\n};\n\nconst BODIES = {\n phone: {width: 480, height: 980, radius: 62, bezel: 14},\n tablet: {width: 900, height: 1180, radius: 40, bezel: 18},\n desktop: {width: 1560, height: 940, radius: 22, bezel: 0},\n} as const;\n\n/**\n * A product stage with a body around it: the same content reads as an app on a\n * phone and as a site on a desktop, and the frame is what tells the viewer\n * which claim is being made.\n *\n * Proportions are the real ones rather than a stylised outline, because a\n * device that is the wrong shape quietly undermines the screenshot inside it.\n * Nothing here is a photograph, so it takes the brand's colours and stays\n * honest at any format.\n */\nexport const DeviceFrame = ({device = \"desktop\", children, url, entranceFrames = 20}: DeviceFrameProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const body = BODIES[device];\n\n const appear = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard});\n const bezel = body.bezel * scale;\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", padding: 60 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `${Math.max(1, 2 * scale)}px solid ${brand.colors.border}`,\n borderRadius: body.radius * scale,\n boxShadow: `0 ${44 * scale}px ${90 * scale}px ${brand.colors.shadow}`,\n // A column, so the screen below the chrome can take the remaining\n // height. Without this the body's `flex: 1` means nothing and the\n // content collapses to zero — the frame renders, and it is empty.\n display: \"flex\",\n flexDirection: \"column\",\n height: body.height * scale,\n maxHeight: \"100%\",\n maxWidth: \"100%\",\n opacity: appear,\n overflow: \"hidden\",\n padding: bezel,\n position: \"relative\",\n transform: `translateY(${(1 - appear) * 26 * scale}px) scale(${0.985 + appear * 0.015})`,\n width: body.width * scale,\n }}\n >\n {device === \"desktop\" ? (\n <div\n style={{\n alignItems: \"center\",\n background: \"rgba(255,255,255,0.02)\",\n borderBottom: `1px solid ${brand.colors.border}`,\n display: \"flex\",\n gap: 9 * scale,\n padding: `${14 * scale}px ${20 * scale}px`,\n }}\n >\n {[0, 1, 2].map((index) => (\n <span\n key={index}\n style={{background: brand.colors.border, borderRadius: 999, height: 11 * scale, width: 11 * scale}}\n />\n ))}\n {url ? (\n <span\n style={{\n background: brand.colors.background,\n borderRadius: 999,\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n // 17px is what a browser really shows and what `odori test`\n // rejects: at 1080p it is a grey smudge. A video is read\n // from further away than a screen is.\n fontSize: 24 * scale,\n marginLeft: 14 * scale,\n padding: `${8 * scale}px ${20 * scale}px`,\n }}\n >\n {url}\n </span>\n ) : null}\n </div>\n ) : null}\n\n <div\n style={{\n background: brand.colors.background,\n borderRadius: Math.max(0, (body.radius - body.bezel) * scale),\n flex: 1,\n overflow: \"hidden\",\n position: \"relative\",\n }}\n >\n {children}\n </div>\n\n {/* The one physical detail worth drawing: a phone's status notch and a\n tablet's home line are what the eye uses to tell them apart. */}\n {device === \"phone\" ? (\n <span\n style={{\n background: brand.colors.background,\n borderRadius: 999,\n height: 26 * scale,\n left: \"50%\",\n position: \"absolute\",\n top: bezel + 10 * scale,\n transform: \"translateX(-50%)\",\n width: 150 * scale,\n }}\n />\n ) : null}\n </div>\n </Fill>\n );\n};\n",
|
|
1707
1827
|
"target": "videos/components/device-frame/device-frame.tsx"
|
|
1708
1828
|
},
|
|
1709
1829
|
{
|
|
@@ -1867,6 +1987,92 @@
|
|
|
1867
1987
|
}
|
|
1868
1988
|
}
|
|
1869
1989
|
},
|
|
1990
|
+
{
|
|
1991
|
+
"name": "dither-field",
|
|
1992
|
+
"description": "A drifting two-colour gradient quantized through a 4x4 Bayer matrix at a chunky pixel size.",
|
|
1993
|
+
"registryDependencies": [],
|
|
1994
|
+
"files": [
|
|
1995
|
+
{
|
|
1996
|
+
"path": "components/dither-field/dither-field.tsx",
|
|
1997
|
+
"content": "import {Fill, useBrand, useDesignScale, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * A two-colour gradient drifting under ordered dither, so the blend is made of\n * visible pixels rather than a smooth ramp.\n *\n * The point of quantizing is the texture: a 4x4 Bayer matrix turns the ramp\n * into the crosshatch every 1-bit display used, and at a chunky pixel size it\n * reads as intentional print rather than as a rendering fault. The pixel size\n * is the control worth reaching for — small is grain, large is mosaic.\n *\n * Pixel size is a design length, scaled outside the shader, so the chunks stay\n * the same apparent size in a vertical or square cut.\n */\nexport type DitherFieldProps = {\n /** Two colours the dither trades between. Defaults to the brand. */\n colors?: [string, string];\n /** Side of one dither pixel, in design pixels. */\n pixelSize?: number;\n /** Quantization steps between the two colours. 2 is pure 1-bit. */\n levels?: number;\n /** How fast the gradient drifts through the grid. */\n speed?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst field = defineShaderEffect({\n name: \"dither-field\",\n uniforms: {\n px: numberUniform(6),\n levels: numberUniform(4),\n speed: numberUniform(0.3),\n low: vec3Uniform([0, 0, 0]),\n high: vec3Uniform([1, 1, 1]),\n },\n fragmentShader: `\nuniform float px;\nuniform float levels;\nuniform float speed;\nuniform vec3 low;\nuniform vec3 high;\n\n/* The classic 4x4 Bayer thresholds, offset to the centre of each step so the\n extremes of the ramp still reach both colours. */\nconst float bayer[16] = float[16](\n 0.0, 8.0, 2.0, 10.0,\n 12.0, 4.0, 14.0, 6.0,\n 3.0, 11.0, 1.0, 9.0,\n 15.0, 7.0, 13.0, 5.0);\n\nvoid main() {\n /* Everything is sampled at the centre of its chunk, so the picture is made\n of whole pixels rather than a dither over a smooth image. */\n vec2 cell = floor(gl_FragCoord.xy / px);\n vec2 uv = (cell + 0.5) * px / resolution;\n uv.x *= resolution.x / resolution.y;\n float t = seconds * speed;\n\n /* The ground being dithered: a diagonal ramp with two slow swells riding it.\n Deliberately simple — the texture is the subject, not the field. */\n float g = dot(uv, normalize(vec2(0.8, 1.0))) * 0.55;\n g += sin(uv.x * 2.1 - t) * 0.16 + sin(uv.y * 1.7 + t * 0.8) * 0.14;\n g = clamp(g * 0.5 + 0.35, 0.0, 1.0);\n\n float threshold = (bayer[int(mod(cell.x, 4.0)) + int(mod(cell.y, 4.0)) * 4] + 0.5) / 16.0;\n float steps = max(levels - 1.0, 1.0);\n float q = floor(g * steps + threshold) / steps;\n odoriColour = vec4(mix(low, high, clamp(q, 0.0, 1.0)), 1.0);\n}`,\n});\n\nexport const DitherField = ({colors, pixelSize = 6, levels = 4, speed = 0.3}: DitherFieldProps) => {\n const brand = useBrand();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n const [low, high] = colors ?? [brand.colors.background, brand.colors.accent];\n\n return (\n <EffectSurface\n effects={[\n field({px: Math.max(1, pixelSize * scale), levels, speed, low: hexToRgb(low), high: hexToRgb(high)}),\n ]}\n >\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n",
|
|
1998
|
+
"target": "videos/components/dither-field/dither-field.tsx"
|
|
1999
|
+
},
|
|
2000
|
+
{
|
|
2001
|
+
"path": "components/dither-field/dither-field.preview.tsx",
|
|
2002
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DitherField} from \"./dither-field\";\n\nexport default defineComponentPreview({\n title: \"Dither field\",\n category: \"Backgrounds\",\n description: \"A drifting two-colour gradient quantized through a 4x4 Bayer matrix at a chunky pixel size.\",\n component: DitherField,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n pixelSize: {type: \"number\", defaultValue: 6, min: 2, max: 24, step: 1},\n levels: {type: \"number\", defaultValue: 4, min: 2, max: 8, step: 1},\n speed: {type: \"number\", defaultValue: 0.3, min: 0, max: 1.5, step: 0.05},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"One bit\", props: {levels: 2, pixelSize: 10}},\n {name: \"Newsprint\", props: {colors: [\"#101012\", \"#3a3f4a\"], pixelSize: 4, levels: 6}},\n ],\n});\n",
|
|
2003
|
+
"target": "videos/components/dither-field/dither-field.preview.tsx"
|
|
2004
|
+
}
|
|
2005
|
+
],
|
|
2006
|
+
"meta": {
|
|
2007
|
+
"kind": "component",
|
|
2008
|
+
"family": "Backgrounds",
|
|
2009
|
+
"namespaced": "@odori/dither-field",
|
|
2010
|
+
"contract": {
|
|
2011
|
+
"aspectRatios": [
|
|
2012
|
+
"16:9",
|
|
2013
|
+
"9:16",
|
|
2014
|
+
"1:1"
|
|
2015
|
+
],
|
|
2016
|
+
"recommendedDurationInFrames": 180,
|
|
2017
|
+
"minimumDurationInFrames": 30,
|
|
2018
|
+
"entranceFrames": 0,
|
|
2019
|
+
"exitFrames": 0,
|
|
2020
|
+
"contentLimits": {},
|
|
2021
|
+
"reducedMotion": "set speed to 0 and the dither holds one still gradient",
|
|
2022
|
+
"requires": {
|
|
2023
|
+
"fonts": [],
|
|
2024
|
+
"audio": [],
|
|
2025
|
+
"graphics": [
|
|
2026
|
+
"webgl2"
|
|
2027
|
+
]
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
},
|
|
2032
|
+
{
|
|
2033
|
+
"name": "dock-bar",
|
|
2034
|
+
"description": "A macOS-style dock where one app launches with a bounce.",
|
|
2035
|
+
"registryDependencies": [],
|
|
2036
|
+
"files": [
|
|
2037
|
+
{
|
|
2038
|
+
"path": "components/dock-bar/dock-bar.tsx",
|
|
2039
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useDesignScale, useFrame} from \"odori\";\n\nexport type DockApp = {\n name: string;\n /** The tile's ground. Assigned from a fixed palette when omitted. */\n color?: string;\n};\n\nexport type DockBarProps = {\n apps?: DockApp[];\n /** The tile the launch beat lands on. */\n launchIndex?: number;\n /** The frame the launch lands on. */\n at?: number;\n /** Peak scale of the launched tile. */\n magnify?: number;\n /** The desktop behind the dock, which gives the translucency its job. */\n children?: ReactNode;\n};\n\n/**\n * Tile grounds when an app does not bring its own. A fixed palette cycled by\n * index, so a dock is colourful and two renders of it agree.\n */\nconst PALETTE = [\"#5B8DEF\", \"#E8684A\", \"#34B37E\", \"#F2B441\", \"#9B6DD7\", \"#4AB8C4\", \"#E064A8\", \"#7A8699\"];\n\n/** Up to two initials, which is all a rounded square can carry legibly. */\nconst initials = (name: string) =>\n name\n .split(\" \")\n .map((word) => word[0] ?? \"\")\n .slice(0, 2)\n .join(\"\")\n .toUpperCase();\n\n/**\n * A macOS-style dock with one scripted launch.\n *\n * The tiles are initials on coloured rounded squares, deliberately: real app\n * marks are other people's trademarks, and a generic dock should read as \"an\n * operating system\" without borrowing anyone's identity.\n *\n * The launch is the classic gesture in three parts, all driven by the frame\n * clock: magnification swells around the focal tile with a cosine falloff so\n * neighbours ease rather than snap, the tile hops twice with the second hop\n * smaller, and the running dot fades in beneath it once it lands.\n */\nexport const DockBar = ({\n apps = [{name: \"Mail\"}, {name: \"Notes\"}, {name: \"Photos\"}, {name: \"Music\"}, {name: \"Maps\"}, {name: \"Files\"}],\n launchIndex = 2,\n at = 34,\n magnify = 1.6,\n children,\n}: DockBarProps) => {\n const frame = useFrame();\n const scale = useDesignScale();\n\n const base = 96 * scale;\n const arrive = interpolate(frame, [0, 12], [0, 1], {easing: Easing.standard});\n // The magnification swells in ahead of the beat and relaxes after it.\n const focus = interpolate(frame, [at - 14, at, at + 34, at + 54], [0, 1, 1, 0], {easing: Easing.standard});\n\n // Two decaying hops, eighteen frames each, entirely a function of the beat.\n const hopTime = frame - at;\n const hop =\n hopTime >= 0 && hopTime < 36\n ? Math.abs(Math.sin((hopTime / 18) * Math.PI)) * (hopTime < 18 ? 1 : 0.4) * 52 * scale\n : 0;\n const dot = interpolate(frame, [at + 26, at + 36], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"flex-end\"}}>\n {children}\n <div\n style={{\n alignItems: \"flex-end\",\n background: \"rgba(255,255,255,0.13)\",\n backdropFilter: `blur(${28 * scale}px)`,\n border: \"1px solid rgba(255,255,255,0.2)\",\n borderRadius: 30 * scale,\n display: \"flex\",\n gap: 14 * scale,\n marginBottom: 40 * scale,\n opacity: arrive,\n padding: `${12 * scale}px ${16 * scale}px`,\n transform: `translateY(${(1 - arrive) * 140 * scale}px)`,\n }}\n >\n {apps.map((app, index) => {\n const distance = Math.abs(index - launchIndex);\n // Cosine falloff over two and a half neighbours on either side.\n const reach = distance >= 2.5 ? 0 : (Math.cos((distance / 2.5) * Math.PI) + 1) / 2;\n const size = base * (1 + (magnify - 1) * reach * focus);\n const launched = index === launchIndex;\n const ground = app.color ?? PALETTE[index % PALETTE.length];\n return (\n <div key={app.name} style={{alignItems: \"center\", display: \"flex\", flexDirection: \"column\"}}>\n <div\n style={{\n alignItems: \"center\",\n background: `linear-gradient(180deg, rgba(255,255,255,0.32), rgba(255,255,255,0) 55%), ${ground}`,\n borderRadius: size * 0.24,\n boxShadow: `0 ${6 * scale}px ${18 * scale}px rgba(0,0,0,0.35)`,\n color: \"#ffffff\",\n display: \"flex\",\n fontSize: size * 0.34,\n fontWeight: 640,\n height: size,\n justifyContent: \"center\",\n letterSpacing: \"-0.02em\",\n transform: launched ? `translateY(${-hop}px)` : undefined,\n width: size,\n }}\n >\n {initials(app.name)}\n </div>\n <span\n style={{\n background: \"rgba(255,255,255,0.9)\",\n borderRadius: 999,\n height: 5 * scale,\n marginTop: 5 * scale,\n opacity: launched ? dot : 0,\n width: 5 * scale,\n }}\n />\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
|
|
2040
|
+
"target": "videos/components/dock-bar/dock-bar.tsx"
|
|
2041
|
+
},
|
|
2042
|
+
{
|
|
2043
|
+
"path": "components/dock-bar/dock-bar.preview.tsx",
|
|
2044
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DockBar} from \"./dock-bar\";\n\n/** A quiet desktop behind the dock, so the translucency has something to do. */\nconst Desktop = () => (\n <div\n style={{\n background: \"radial-gradient(120% 90% at 30% 10%, #274060 0%, #0d1420 70%)\",\n inset: 0,\n position: \"absolute\",\n }}\n />\n);\n\nexport default defineComponentPreview({\n title: \"Dock bar\",\n category: \"Products\",\n description: \"A macOS-style dock where one app launches with a bounce.\",\n component: DockBar,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n launchIndex: {type: \"number\", defaultValue: 2, min: 0, max: 7},\n at: {type: \"number\", defaultValue: 34, min: 14, max: 100},\n magnify: {type: \"number\", defaultValue: 1.6, min: 1.1, max: 2.2, step: 0.05},\n },\n examples: [\n {name: \"Default\", props: {children: <Desktop />}},\n {\n name: \"Workspace\",\n props: {\n children: <Desktop />,\n apps: [\n {name: \"Mail\", color: \"#4A90D9\"},\n {name: \"Calendar\", color: \"#E8684A\"},\n {name: \"Odori Studio\", color: \"#7C3AED\"},\n {name: \"Terminal\", color: \"#2E3440\"},\n {name: \"Figma Files\", color: \"#34B37E\"},\n ],\n launchIndex: 2,\n at: 40,\n },\n },\n {name: \"Subtle\", props: {children: <Desktop />, launchIndex: 4, magnify: 1.3, at: 50}},\n ],\n});\n",
|
|
2045
|
+
"target": "videos/components/dock-bar/dock-bar.preview.tsx"
|
|
2046
|
+
}
|
|
2047
|
+
],
|
|
2048
|
+
"meta": {
|
|
2049
|
+
"kind": "component",
|
|
2050
|
+
"family": "Products",
|
|
2051
|
+
"namespaced": "@odori/dock-bar",
|
|
2052
|
+
"contract": {
|
|
2053
|
+
"aspectRatios": [
|
|
2054
|
+
"16:9",
|
|
2055
|
+
"9:16",
|
|
2056
|
+
"1:1"
|
|
2057
|
+
],
|
|
2058
|
+
"recommendedDurationInFrames": 150,
|
|
2059
|
+
"minimumDurationInFrames": 90,
|
|
2060
|
+
"entranceFrames": 12,
|
|
2061
|
+
"exitFrames": 0,
|
|
2062
|
+
"contentLimits": {
|
|
2063
|
+
"apps": 8,
|
|
2064
|
+
"appNameLength": 16
|
|
2065
|
+
},
|
|
2066
|
+
"reducedMotion": "the launched tile is shown magnified with its dot, without the bounce",
|
|
2067
|
+
"requires": {
|
|
2068
|
+
"fonts": [
|
|
2069
|
+
"sans"
|
|
2070
|
+
],
|
|
2071
|
+
"audio": []
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
},
|
|
1870
2076
|
{
|
|
1871
2077
|
"name": "donut-chart",
|
|
1872
2078
|
"description": "Part-to-whole display with a stable center metric.",
|
|
@@ -1911,6 +2117,49 @@
|
|
|
1911
2117
|
}
|
|
1912
2118
|
}
|
|
1913
2119
|
},
|
|
2120
|
+
{
|
|
2121
|
+
"name": "draw-title",
|
|
2122
|
+
"description": "An outline draws across the line, then fills solid.",
|
|
2123
|
+
"registryDependencies": [],
|
|
2124
|
+
"files": [
|
|
2125
|
+
{
|
|
2126
|
+
"path": "components/draw-title/draw-title.tsx",
|
|
2127
|
+
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type DrawTitleProps = {\n text: string;\n detail?: string;\n /** Frames the outline takes to draw across the line. */\n drawFrames?: number;\n /** Frames between the outline finishing and the fill starting. */\n fillDelay?: number;\n /** Stroke width in design pixels. */\n strokeWidth?: number;\n};\n\n/**\n * Outline type that draws on, then fills. The text is rendered twice and\n * stacked: a stroked, hollow layer revealed left to right by a frame-driven\n * clip inset, then the solid layer wiped in the same direction once the\n * outline pass completes. Both layers are the same string in the same box,\n * so they register exactly and nothing reflows.\n */\nexport const DrawTitle = ({text, detail, drawFrames = 40, fillDelay = 8, strokeWidth = 2}: DrawTitleProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const draw = interpolate(frame, [4, 4 + drawFrames], [0, 1], {easing: Easing.standard});\n const fillStart = 4 + drawFrames + fillDelay;\n const fill = interpolate(frame, [fillStart, fillStart + 24], [0, 1], {easing: Easing.standard});\n const layer = {\n fontFamily: brand.typography.display ?? brand.typography.sans,\n fontSize: 148 * scale,\n fontWeight: 650,\n gridArea: \"1 / 1\",\n letterSpacing: \"-0.03em\",\n lineHeight: 1.04,\n whiteSpace: \"pre\",\n } as const;\n\n return (\n <Fill\n style={{\n alignItems: \"center\",\n gap: 34 * scale,\n justifyContent: \"center\",\n padding: `${120 * scale}px ${140 * scale}px`,\n }}\n >\n <div style={{display: \"grid\"}}>\n <div\n style={{\n ...layer,\n WebkitTextStroke: `${strokeWidth * scale}px ${brand.colors.foreground}`,\n WebkitTextFillColor: \"transparent\",\n clipPath: `inset(0 ${(1 - draw) * 100}% 0 0)`,\n color: \"transparent\",\n }}\n >\n {text}\n </div>\n <div\n style={{\n ...layer,\n clipPath: `inset(0 ${(1 - fill) * 100}% 0 0)`,\n color: brand.colors.foreground,\n opacity: fill,\n }}\n >\n {text}\n </div>\n </div>\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 34 * scale,\n opacity: interpolate(frame, [fillStart + 20, fillStart + 38], [0, 1], {easing: Easing.standard}),\n }}\n >\n {detail}\n </div>\n ) : null}\n </Fill>\n );\n};\n",
|
|
2128
|
+
"target": "videos/components/draw-title/draw-title.tsx"
|
|
2129
|
+
},
|
|
2130
|
+
{
|
|
2131
|
+
"path": "components/draw-title/draw-title.preview.tsx",
|
|
2132
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DrawTitle} from \"./draw-title\";\n\nexport default defineComponentPreview({\n title: \"Draw title\",\n category: \"Typography/Titles\",\n description: \"An outline draws across the line, then fills solid.\",\n component: DrawTitle,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"Drawn out\", maxLength: 20},\n detail: {type: \"text\", defaultValue: \"Stroke first, substance second.\"},\n drawFrames: {type: \"number\", defaultValue: 40, min: 12, max: 90},\n fillDelay: {type: \"number\", defaultValue: 8, min: 0, max: 40},\n strokeWidth: {type: \"number\", defaultValue: 2, min: 1, max: 6},\n },\n examples: [\n {name: \"Default\", props: {text: \"Drawn out\", detail: \"Stroke first, substance second.\"}},\n {name: \"Heavy stroke\", props: {text: \"Bold\", strokeWidth: 5, drawFrames: 60}},\n ],\n});\n",
|
|
2133
|
+
"target": "videos/components/draw-title/draw-title.preview.tsx"
|
|
2134
|
+
}
|
|
2135
|
+
],
|
|
2136
|
+
"meta": {
|
|
2137
|
+
"kind": "component",
|
|
2138
|
+
"family": "Typography",
|
|
2139
|
+
"namespaced": "@odori/draw-title",
|
|
2140
|
+
"contract": {
|
|
2141
|
+
"aspectRatios": [
|
|
2142
|
+
"16:9",
|
|
2143
|
+
"9:16",
|
|
2144
|
+
"1:1"
|
|
2145
|
+
],
|
|
2146
|
+
"recommendedDurationInFrames": 150,
|
|
2147
|
+
"minimumDurationInFrames": 90,
|
|
2148
|
+
"entranceFrames": 76,
|
|
2149
|
+
"exitFrames": 0,
|
|
2150
|
+
"contentLimits": {
|
|
2151
|
+
"text": 20
|
|
2152
|
+
},
|
|
2153
|
+
"reducedMotion": "the filled title fades in without the outline pass",
|
|
2154
|
+
"requires": {
|
|
2155
|
+
"fonts": [
|
|
2156
|
+
"sans"
|
|
2157
|
+
],
|
|
2158
|
+
"audio": []
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
},
|
|
1914
2163
|
{
|
|
1915
2164
|
"name": "dropdown-menu",
|
|
1916
2165
|
"description": "A menu opening from its trigger, the highlight walking to the item chosen.",
|
|
@@ -1918,7 +2167,7 @@
|
|
|
1918
2167
|
"files": [
|
|
1919
2168
|
{
|
|
1920
2169
|
"path": "components/dropdown-menu/dropdown-menu.tsx",
|
|
1921
|
-
"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:
|
|
2170
|
+
"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: brand.colors.background, 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 ${brand.colors.border}`,\n borderRadius: px(10),\n color: brand.colors.foreground,\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: brand.colors.surface,\n border: `${px(1)}px solid ${brand.colors.border}`,\n borderRadius: px(12),\n boxShadow: `0 ${px(24)}px ${px(70)}px ${brand.colors.shadow}`,\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: brand.colors.surface, 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",
|
|
1922
2171
|
"target": "videos/components/dropdown-menu/dropdown-menu.tsx"
|
|
1923
2172
|
},
|
|
1924
2173
|
{
|
|
@@ -1954,17 +2203,56 @@
|
|
|
1954
2203
|
}
|
|
1955
2204
|
},
|
|
1956
2205
|
{
|
|
1957
|
-
"name": "
|
|
1958
|
-
"description": "
|
|
2206
|
+
"name": "dust-motes",
|
|
2207
|
+
"description": "A seeded field of soft dots, each breathing around its own home position.",
|
|
1959
2208
|
"registryDependencies": [],
|
|
1960
2209
|
"files": [
|
|
1961
2210
|
{
|
|
1962
|
-
"path": "components/
|
|
1963
|
-
"content": "import {Fill,
|
|
1964
|
-
"target": "videos/components/
|
|
2211
|
+
"path": "components/dust-motes/dust-motes.tsx",
|
|
2212
|
+
"content": "import {Fill, random, randomBetween, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\n/**\n * A still field of soft dots, each breathing around its own home position.\n *\n * Particles that travel across the frame become an event; particles that orbit\n * a fixed point a few pixels wide become air. Every home position, size, and\n * phase comes from a seed named after the particle, so the field is arranged\n * once and forever — the same fifty dots on every machine, in every render\n * chunk, with nothing to jump at a boundary.\n *\n * Drift is the control worth having: it is the orbit radius, and at 0 the\n * field is a designed still rather than a paused animation.\n */\nexport type DustMotesProps = {\n /** How many dots. */\n count?: number;\n /** Largest dot diameter, in design pixels. */\n size?: number;\n /** Orbit radius, in design pixels. 0 holds every dot at home. */\n drift?: number;\n /** Dot colour. Defaults to the brand's muted tone. */\n color?: string;\n /** Opacity of the brightest dot. */\n opacity?: number;\n};\n\nexport const DustMotes = ({count = 48, size = 7, drift = 24, color, opacity = 0.7}: DustMotesProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {fps} = useVideo();\n const dot = color ?? brand.colors.muted;\n const t = frame / fps;\n\n return (\n <Fill style={{background: brand.colors.background, overflow: \"hidden\"}}>\n {Array.from({length: count}, (_, index) => {\n const home = {\n x: random(`particle-${index}-x`) * 100,\n y: random(`particle-${index}-y`) * 100,\n };\n const phase = random(`particle-${index}-phase`) * Math.PI * 2;\n const rate = randomBetween(`particle-${index}-rate`, 0.04, 0.1) * Math.PI * 2;\n // Depth from size: the small dots are also the dim ones, which is\n // enough to read as distance without any actual parallax.\n const depth = randomBetween(`particle-${index}-depth`, 0.35, 1);\n const diameter = size * depth * scale;\n\n const x = Math.sin(t * rate + phase) * drift * depth * scale;\n const y = Math.cos(t * rate * 0.8 + phase * 1.6) * drift * depth * scale;\n\n return (\n <div\n key={index}\n aria-hidden\n style={{\n background: `radial-gradient(circle, ${dot} 0%, transparent 70%)`,\n borderRadius: \"50%\",\n height: diameter * 3,\n left: `${home.x}%`,\n opacity: opacity * depth,\n position: \"absolute\",\n top: `${home.y}%`,\n transform: `translate(${x}px, ${y}px)`,\n width: diameter * 3,\n }}\n />\n );\n })}\n </Fill>\n );\n};\n",
|
|
2213
|
+
"target": "videos/components/dust-motes/dust-motes.tsx"
|
|
1965
2214
|
},
|
|
1966
2215
|
{
|
|
1967
|
-
"path": "components/
|
|
2216
|
+
"path": "components/dust-motes/dust-motes.preview.tsx",
|
|
2217
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {DustMotes} from \"./dust-motes\";\n\nexport default defineComponentPreview({\n title: \"Dust motes\",\n category: \"Backgrounds\",\n description: \"A seeded field of soft dots, each breathing around its own home position.\",\n component: DustMotes,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n count: {type: \"number\", defaultValue: 48, min: 8, max: 160, step: 8},\n drift: {type: \"number\", defaultValue: 24, min: 0, max: 120, step: 4},\n opacity: {type: \"number\", defaultValue: 0.7, min: 0.05, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Dust\", props: {count: 120, size: 3, drift: 12, opacity: 0.25}},\n {name: \"Embers\", props: {color: \"#ff9a3d\", count: 32, size: 8, drift: 48}},\n ],\n});\n",
|
|
2218
|
+
"target": "videos/components/dust-motes/dust-motes.preview.tsx"
|
|
2219
|
+
}
|
|
2220
|
+
],
|
|
2221
|
+
"meta": {
|
|
2222
|
+
"kind": "component",
|
|
2223
|
+
"family": "Backgrounds",
|
|
2224
|
+
"namespaced": "@odori/dust-motes",
|
|
2225
|
+
"contract": {
|
|
2226
|
+
"aspectRatios": [
|
|
2227
|
+
"16:9",
|
|
2228
|
+
"9:16",
|
|
2229
|
+
"1:1"
|
|
2230
|
+
],
|
|
2231
|
+
"recommendedDurationInFrames": 180,
|
|
2232
|
+
"minimumDurationInFrames": 30,
|
|
2233
|
+
"entranceFrames": 0,
|
|
2234
|
+
"exitFrames": 0,
|
|
2235
|
+
"contentLimits": {},
|
|
2236
|
+
"reducedMotion": "set drift to 0 and every dot holds its seeded home position",
|
|
2237
|
+
"requires": {
|
|
2238
|
+
"fonts": [],
|
|
2239
|
+
"audio": []
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
},
|
|
2244
|
+
{
|
|
2245
|
+
"name": "end-card",
|
|
2246
|
+
"description": "The closing frame: title, supporting line, and a call to action.",
|
|
2247
|
+
"registryDependencies": [],
|
|
2248
|
+
"files": [
|
|
2249
|
+
{
|
|
2250
|
+
"path": "components/end-card/end-card.tsx",
|
|
2251
|
+
"content": "import {Fill, Easing, interpolate, spring, useBrand, useFrame, useDesignScale, useVideo} from \"odori\";\n\nexport type EndCardProps = {\n title: string;\n detail?: string;\n callToAction?: string;\n};\n\n/** The closing lockup: the statement, and one command to run. */\nexport const EndCard = ({title, detail, callToAction}: EndCardProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const {fps} = useVideo();\n const scale = useDesignScale();\n const rise = spring({frame, fps, from: 0.96, to: 1, damping: 20});\n const line = interpolate(frame, [8, 34], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", gap: 30 * scale, justifyContent: \"center\", padding: 110 * scale}}>\n <div\n style={{\n fontSize: 108 * scale,\n fontWeight: 560,\n letterSpacing: \"-0.05em\",\n lineHeight: 1.02,\n maxWidth: 1400 * scale,\n opacity: interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard}),\n textAlign: \"center\",\n transform: `scale(${rise})`,\n whiteSpace: \"pre-line\",\n }}\n >\n {title}\n </div>\n\n <div\n style={{\n background: `linear-gradient(90deg, transparent, ${brand.colors.border}, transparent)`,\n height: 1,\n opacity: line,\n width: `${line * 46}%`,\n }}\n />\n\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 34 * scale,\n letterSpacing: \"-0.015em\",\n opacity: interpolate(frame, [14, 30], [0, 1], {easing: Easing.standard}),\n textAlign: \"center\",\n }}\n >\n {detail}\n </div>\n ) : null}\n\n {callToAction ? (\n <div\n style={{\n alignItems: \"center\",\n background: \"rgba(255,255,255,0.04)\",\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 10 * scale,\n color: brand.colors.foreground,\n display: \"flex\",\n fontFamily: brand.typography.mono,\n fontSize: 28 * scale,\n gap: 14 * scale,\n marginTop: 8 * scale,\n opacity: interpolate(frame, [22, 38], [0, 1], {easing: Easing.standard}),\n padding: `${16 * scale}px ${28 * scale}px`,\n transform: `translateY(${interpolate(frame, [22, 38], [12 * scale, 0], {easing: Easing.standard})}px)`,\n }}\n >\n <span style={{color: brand.colors.muted}}>{\"$\"}</span>\n {callToAction}\n </div>\n ) : null}\n </Fill>\n );\n};\n",
|
|
2252
|
+
"target": "videos/components/end-card/end-card.tsx"
|
|
2253
|
+
},
|
|
2254
|
+
{
|
|
2255
|
+
"path": "components/end-card/end-card.preview.tsx",
|
|
1968
2256
|
"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",
|
|
1969
2257
|
"target": "videos/components/end-card/end-card.preview.tsx"
|
|
1970
2258
|
}
|
|
@@ -2132,6 +2420,92 @@
|
|
|
2132
2420
|
}
|
|
2133
2421
|
}
|
|
2134
2422
|
},
|
|
2423
|
+
{
|
|
2424
|
+
"name": "flap-board",
|
|
2425
|
+
"description": "A split-flap board clatters through to the target string.",
|
|
2426
|
+
"registryDependencies": [],
|
|
2427
|
+
"files": [
|
|
2428
|
+
{
|
|
2429
|
+
"path": "components/flap-board/flap-board.tsx",
|
|
2430
|
+
"content": "import {Fill, random, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type FlapBoardProps = {\n text: string;\n /** Frames between one cell starting and the next. */\n stagger?: number;\n /** Intermediate characters each cell flips through. */\n flips?: number;\n /** Render the dark tiles, or bare glyphs on the background. */\n tile?: boolean;\n};\n\nconst FLAP_ALPHABET = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\";\n/** Frames one flap takes; short enough to clatter, long enough to read. */\nconst FLIP_FRAMES = 6;\n\n/**\n * A split-flap departure board. Each cell is a fixed-width tile that flips\n * through seeded intermediates before landing on its glyph, settling left to\n * right. A flap is a vertical squash through zero with the character swapped\n * at the midpoint — the moment the tile is edge-on and nobody can see the\n * switch. The intermediates are seeded by cell and flap index, so every\n * render clatters through the same sequence.\n */\nexport const FlapBoard = ({text, stagger = 4, flips = 5, tile = true}: FlapBoardProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const cells = [...text];\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", padding: `${120 * scale}px ${140 * scale}px`}}>\n <div style={{display: \"flex\", gap: 8 * scale}}>\n {cells.map((target, index) => {\n const blank = target === \" \";\n const t = Math.min(flips, (frame - (4 + index * stagger)) / FLIP_FRAMES);\n const settled = blank || t >= flips;\n // The shown character advances at the midpoint of each flap.\n const step = Math.max(0, Math.min(flips, Math.round(t)));\n const intermediate = FLAP_ALPHABET[Math.floor(random([\"flap\", index, step]) * FLAP_ALPHABET.length)];\n const shown = blank ? \"\" : step >= flips ? target : intermediate;\n const squash = settled || t < 0 ? 1 : Math.abs(Math.cos(Math.PI * t));\n return (\n <div\n key={`${target}-${index}`}\n style={{\n alignItems: \"center\",\n background: tile ? brand.colors.surface : \"transparent\",\n border: tile ? `1px solid ${brand.colors.border}` : \"none\",\n borderRadius: 8 * scale,\n display: \"flex\",\n fontFamily: brand.typography.mono,\n fontSize: 88 * scale,\n fontWeight: 600,\n height: 128 * scale,\n justifyContent: \"center\",\n position: \"relative\",\n transform: `scaleY(${squash})`,\n width: 76 * scale,\n }}\n >\n <span style={{whiteSpace: \"pre\"}}>{shown}</span>\n {tile ? (\n // The hairline seam where a real board's two flaps meet.\n <div\n style={{\n background: brand.colors.background,\n height: Math.max(1, 2 * scale),\n left: 0,\n position: \"absolute\",\n right: 0,\n top: \"50%\",\n }}\n />\n ) : null}\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
|
|
2431
|
+
"target": "videos/components/flap-board/flap-board.tsx"
|
|
2432
|
+
},
|
|
2433
|
+
{
|
|
2434
|
+
"path": "components/flap-board/flap-board.preview.tsx",
|
|
2435
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {FlapBoard} from \"./flap-board\";\n\nexport default defineComponentPreview({\n title: \"Flap board\",\n category: \"Typography/Titles\",\n description: \"A split-flap board clatters through to the target string.\",\n component: FlapBoard,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"NOW BOARDING\", maxLength: 16},\n stagger: {type: \"number\", defaultValue: 4, min: 0, max: 12},\n flips: {type: \"number\", defaultValue: 5, min: 1, max: 12},\n tile: {type: \"boolean\", defaultValue: true},\n },\n examples: [\n {name: \"Default\", props: {text: \"NOW BOARDING\"}},\n {name: \"Long clatter\", props: {text: \"DELAYED\", flips: 10, stagger: 6}},\n {name: \"Bare glyphs\", props: {text: \"GATE 42\", tile: false}},\n ],\n});\n",
|
|
2436
|
+
"target": "videos/components/flap-board/flap-board.preview.tsx"
|
|
2437
|
+
}
|
|
2438
|
+
],
|
|
2439
|
+
"meta": {
|
|
2440
|
+
"kind": "component",
|
|
2441
|
+
"family": "Typography",
|
|
2442
|
+
"namespaced": "@odori/flap-board",
|
|
2443
|
+
"contract": {
|
|
2444
|
+
"aspectRatios": [
|
|
2445
|
+
"16:9",
|
|
2446
|
+
"9:16",
|
|
2447
|
+
"1:1"
|
|
2448
|
+
],
|
|
2449
|
+
"recommendedDurationInFrames": 150,
|
|
2450
|
+
"minimumDurationInFrames": 100,
|
|
2451
|
+
"entranceFrames": 94,
|
|
2452
|
+
"exitFrames": 0,
|
|
2453
|
+
"contentLimits": {
|
|
2454
|
+
"text": 16
|
|
2455
|
+
},
|
|
2456
|
+
"reducedMotion": "cells show their target characters without flipping",
|
|
2457
|
+
"requires": {
|
|
2458
|
+
"fonts": [
|
|
2459
|
+
"mono"
|
|
2460
|
+
],
|
|
2461
|
+
"audio": []
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
},
|
|
2466
|
+
{
|
|
2467
|
+
"name": "focus-in",
|
|
2468
|
+
"description": "Words settle from a soft blur into a crisp line.",
|
|
2469
|
+
"registryDependencies": [],
|
|
2470
|
+
"files": [
|
|
2471
|
+
{
|
|
2472
|
+
"path": "components/focus-in/focus-in.tsx",
|
|
2473
|
+
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type FocusInProps = {\n text: string;\n detail?: string;\n /** Frames between one word and the next. */\n stagger?: number;\n /** Starting blur radius in design pixels. */\n blur?: number;\n};\n\n/**\n * Each word starts blurred, slightly oversized and transparent, then settles\n * crisp in place. The size change is a transform rather than a font-size\n * change, so the line is laid out once and never reflows while a word is\n * still soft.\n */\nexport const FocusIn = ({text, detail, stagger = 5, blur = 22}: FocusInProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const words = text.split(/\\s+/).filter(Boolean);\n const settled = 6 + words.length * stagger + 26;\n\n return (\n <Fill\n style={{\n alignItems: \"center\",\n gap: 32 * scale,\n justifyContent: \"center\",\n padding: `${120 * scale}px ${140 * scale}px`,\n }}\n >\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: `0 ${30 * scale}px`,\n justifyContent: \"center\",\n maxWidth: 1560 * scale,\n }}\n >\n {words.map((word, index) => {\n const delay = 6 + index * stagger;\n const progress = interpolate(frame, [delay, delay + 26], [0, 1], {easing: Easing.standard});\n return (\n <span\n key={`${word}-${index}`}\n style={{\n display: \"inline-block\",\n filter: `blur(${(1 - progress) * blur * scale}px)`,\n fontSize: 120 * scale,\n fontWeight: 580,\n letterSpacing: \"-0.04em\",\n lineHeight: 1.06,\n opacity: progress,\n transform: `scale(${1 + (1 - progress) * 0.08})`,\n }}\n >\n {word}\n </span>\n );\n })}\n </div>\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 34 * scale,\n opacity: interpolate(frame, [settled, settled + 18], [0, 1], {easing: Easing.standard}),\n }}\n >\n {detail}\n </div>\n ) : null}\n </Fill>\n );\n};\n",
|
|
2474
|
+
"target": "videos/components/focus-in/focus-in.tsx"
|
|
2475
|
+
},
|
|
2476
|
+
{
|
|
2477
|
+
"path": "components/focus-in/focus-in.preview.tsx",
|
|
2478
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {FocusIn} from \"./focus-in\";\n\nexport default defineComponentPreview({\n title: \"Focus in\",\n category: \"Typography/Titles\",\n description: \"Words settle from a soft blur into a crisp line.\",\n component: FocusIn,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"Clarity takes a moment\", maxLength: 48},\n detail: {type: \"text\", defaultValue: \"Then it holds.\"},\n stagger: {type: \"number\", defaultValue: 5, min: 1, max: 12},\n blur: {type: \"number\", defaultValue: 22, min: 4, max: 60},\n },\n examples: [\n {name: \"Default\", props: {text: \"Clarity takes a moment\", detail: \"Then it holds.\"}},\n {name: \"Heavy blur\", props: {text: \"Out of the fog\", blur: 48, stagger: 8}},\n ],\n});\n",
|
|
2479
|
+
"target": "videos/components/focus-in/focus-in.preview.tsx"
|
|
2480
|
+
}
|
|
2481
|
+
],
|
|
2482
|
+
"meta": {
|
|
2483
|
+
"kind": "component",
|
|
2484
|
+
"family": "Typography",
|
|
2485
|
+
"namespaced": "@odori/focus-in",
|
|
2486
|
+
"contract": {
|
|
2487
|
+
"aspectRatios": [
|
|
2488
|
+
"16:9",
|
|
2489
|
+
"9:16",
|
|
2490
|
+
"1:1"
|
|
2491
|
+
],
|
|
2492
|
+
"recommendedDurationInFrames": 120,
|
|
2493
|
+
"minimumDurationInFrames": 70,
|
|
2494
|
+
"entranceFrames": 62,
|
|
2495
|
+
"exitFrames": 0,
|
|
2496
|
+
"contentLimits": {
|
|
2497
|
+
"text": 48
|
|
2498
|
+
},
|
|
2499
|
+
"reducedMotion": "words fade in without blur or scale",
|
|
2500
|
+
"requires": {
|
|
2501
|
+
"fonts": [
|
|
2502
|
+
"sans"
|
|
2503
|
+
],
|
|
2504
|
+
"audio": []
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
},
|
|
2135
2509
|
{
|
|
2136
2510
|
"name": "form-flow",
|
|
2137
2511
|
"description": "Fields, validation, submit, and the success it resolves to.",
|
|
@@ -2139,7 +2513,7 @@
|
|
|
2139
2513
|
"files": [
|
|
2140
2514
|
{
|
|
2141
2515
|
"path": "components/form-flow/form-flow.tsx",
|
|
2142
|
-
"content": "import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type FormField = {\n label: string;\n /** What gets typed into it. */\n value: string;\n /** Frame the typing starts. */\n at: number;\n /** A validation message that appears, then clears when the value is fixed. */\n error?: string;\n};\n\nexport type FormFlowProps = {\n title?: string;\n fields?: FormField[];\n submit?: string;\n /** Frame the submit is pressed. */\n submitAt?: number;\n /** What replaces the form once it succeeds. */\n success?: string;\n};\n\n/**\n * Fields, validation, submit, success — the whole progression, driven by\n * frames.\n *\n * Typing is drawn character by character from the frame clock rather than\n * animated with a CSS trick, so the same characters appear on the same frames\n * in preview and in the export, and a scrub backwards untypes them.\n */\nexport const FormFlow = ({\n title = \"Create a project\",\n fields = [],\n submit = \"Create\",\n submitAt = 100,\n success = \"Project created\",\n}: FormFlowProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const appear = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const pressed = interpolate(frame, [submitAt - 3, submitAt, submitAt + 6], [0, 1, 0]);\n const done = interpolate(frame, [submitAt + 6, submitAt + 20], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", opacity: appear, padding: 80 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 24 * scale,\n display: \"flex\",\n flexDirection: \"column\",\n gap: 26 * scale,\n padding: 52 * scale,\n position: \"relative\",\n width: 880 * scale,\n }}\n >\n <strong\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.sans,\n fontSize: 40 * scale,\n fontWeight: 600,\n letterSpacing: \"-0.025em\",\n opacity: 1 - done,\n }}\n >\n {title}\n </strong>\n\n {fields.map((field) => {\n // Six frames a character: fast enough to read as typing, slow\n // enough that the viewer can follow what is being written.\n const typed = Math.max(0, Math.min(field.value.length, Math.floor((frame - field.at) / 3)));\n const text = field.value.slice(0, typed);\n const complete = typed >= field.value.length;\n const focused = frame >= field.at && !complete;\n const showError = Boolean(field.error) && typed > 0 && !complete;\n\n return (\n <label key={field.label} style={{display: \"flex\", flexDirection: \"column\", gap: 10 * scale, opacity: 1 - done}}>\n <span style={{color: brand.colors.muted, fontFamily: brand.typography.sans, fontSize: 22 * scale}}>\n {field.label}\n </span>\n <span\n style={{\n background: brand.colors.background,\n border: `1px solid ${showError ? \"#e5484d\" : focused ? brand.colors.accent : brand.colors.border}`,\n borderRadius: 12 * scale,\n color: brand.colors.foreground,\n fontFamily: brand.typography.mono,\n fontSize: 26 * scale,\n minHeight: 34 * scale,\n padding: `${18 * scale}px ${22 * scale}px`,\n }}\n >\n {text}\n {focused ? (\n // The caret blinks on the frame clock, not on wall time.\n <span style={{opacity: Math.floor(frame / 8) % 2 === 0 ? 1 : 0.15}}>│</span>\n ) : null}\n </span>\n {showError ? (\n <span style={{color:
|
|
2516
|
+
"content": "import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type FormField = {\n label: string;\n /** What gets typed into it. */\n value: string;\n /** Frame the typing starts. */\n at: number;\n /** A validation message that appears, then clears when the value is fixed. */\n error?: string;\n};\n\nexport type FormFlowProps = {\n title?: string;\n fields?: FormField[];\n submit?: string;\n /** Frame the submit is pressed. */\n submitAt?: number;\n /** What replaces the form once it succeeds. */\n success?: string;\n};\n\n/**\n * Fields, validation, submit, success — the whole progression, driven by\n * frames.\n *\n * Typing is drawn character by character from the frame clock rather than\n * animated with a CSS trick, so the same characters appear on the same frames\n * in preview and in the export, and a scrub backwards untypes them.\n */\nexport const FormFlow = ({\n title = \"Create a project\",\n fields = [],\n submit = \"Create\",\n submitAt = 100,\n success = \"Project created\",\n}: FormFlowProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const appear = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const pressed = interpolate(frame, [submitAt - 3, submitAt, submitAt + 6], [0, 1, 0]);\n const done = interpolate(frame, [submitAt + 6, submitAt + 20], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", opacity: appear, padding: 80 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 24 * scale,\n display: \"flex\",\n flexDirection: \"column\",\n gap: 26 * scale,\n padding: 52 * scale,\n position: \"relative\",\n width: 880 * scale,\n }}\n >\n <strong\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.sans,\n fontSize: 40 * scale,\n fontWeight: 600,\n letterSpacing: \"-0.025em\",\n opacity: 1 - done,\n }}\n >\n {title}\n </strong>\n\n {fields.map((field) => {\n // Six frames a character: fast enough to read as typing, slow\n // enough that the viewer can follow what is being written.\n const typed = Math.max(0, Math.min(field.value.length, Math.floor((frame - field.at) / 3)));\n const text = field.value.slice(0, typed);\n const complete = typed >= field.value.length;\n const focused = frame >= field.at && !complete;\n const showError = Boolean(field.error) && typed > 0 && !complete;\n\n return (\n <label key={field.label} style={{display: \"flex\", flexDirection: \"column\", gap: 10 * scale, opacity: 1 - done}}>\n <span style={{color: brand.colors.muted, fontFamily: brand.typography.sans, fontSize: 22 * scale}}>\n {field.label}\n </span>\n <span\n style={{\n background: brand.colors.background,\n border: `1px solid ${showError ? \"#e5484d\" : focused ? brand.colors.accent : brand.colors.border}`,\n borderRadius: 12 * scale,\n color: brand.colors.foreground,\n fontFamily: brand.typography.mono,\n fontSize: 26 * scale,\n minHeight: 34 * scale,\n padding: `${18 * scale}px ${22 * scale}px`,\n }}\n >\n {text}\n {focused ? (\n // The caret blinks on the frame clock, not on wall time.\n <span style={{opacity: Math.floor(frame / 8) % 2 === 0 ? 1 : 0.15}}>│</span>\n ) : null}\n </span>\n {showError ? (\n <span style={{color: brand.colors.muted, fontFamily: brand.typography.sans, fontSize: 20 * scale}}>\n {field.error}\n </span>\n ) : null}\n </label>\n );\n })}\n\n <span\n style={{\n background: brand.colors.accent,\n borderRadius: 12 * scale,\n color: brand.colors.background,\n fontFamily: brand.typography.sans,\n fontSize: 27 * scale,\n fontWeight: 560,\n opacity: 1 - done,\n padding: `${18 * scale}px ${30 * scale}px`,\n textAlign: \"center\",\n transform: `scale(${1 - pressed * 0.03})`,\n }}\n >\n {submit}\n </span>\n\n {success ? (\n <div\n style={{\n alignItems: \"center\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 16 * scale,\n inset: 0,\n justifyContent: \"center\",\n opacity: done,\n position: \"absolute\",\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: brand.colors.accent,\n borderRadius: 999,\n color: brand.colors.background,\n display: \"flex\",\n fontSize: 34 * scale,\n height: 76 * scale,\n justifyContent: \"center\",\n transform: `scale(${0.8 + done * 0.2})`,\n width: 76 * scale,\n }}\n >\n ✓\n </span>\n <strong\n style={{color: brand.colors.foreground, fontFamily: brand.typography.sans, fontSize: 34 * scale}}\n >\n {success}\n </strong>\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
|
|
2143
2517
|
"target": "videos/components/form-flow/form-flow.tsx"
|
|
2144
2518
|
},
|
|
2145
2519
|
{
|
|
@@ -2184,7 +2558,7 @@
|
|
|
2184
2558
|
"files": [
|
|
2185
2559
|
{
|
|
2186
2560
|
"path": "components/fx/fx.tsx",
|
|
2187
|
-
"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:
|
|
2561
|
+
"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: brand.colors.background, 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",
|
|
2188
2562
|
"target": "videos/components/fx/fx.tsx"
|
|
2189
2563
|
},
|
|
2190
2564
|
{
|
|
@@ -2219,6 +2593,45 @@
|
|
|
2219
2593
|
}
|
|
2220
2594
|
}
|
|
2221
2595
|
},
|
|
2596
|
+
{
|
|
2597
|
+
"name": "gel-wash",
|
|
2598
|
+
"description": "Blurred, tilted colour curtains drifting on independent sine paths over a dark ground.",
|
|
2599
|
+
"registryDependencies": [],
|
|
2600
|
+
"files": [
|
|
2601
|
+
{
|
|
2602
|
+
"path": "components/gel-wash/gel-wash.tsx",
|
|
2603
|
+
"content": "import {Fill, random, randomBetween, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\n/**\n * Two or three blurred colour curtains hanging across the frame, each drifting\n * on its own slow sine path.\n *\n * The aurora look is skew: a straight gradient bar, tilted and heavily blurred,\n * reads as a sheet of light seen edge-on. The curtains never share a rate or a\n * phase — each takes its own from a seed — so the motion never synchronizes\n * into an obvious loop, and never disagrees between preview and export either.\n *\n * Intensity is a ceiling, not a glow: this sits under titles at full opacity,\n * so the default keeps every curtain translucent and the ground dark.\n */\nexport type GelWashProps = {\n /** One colour per curtain, cycled if short. Defaults to the brand. */\n colors?: string[];\n /** Opacity ceiling for the brightest curtain. */\n intensity?: number;\n /** How fast the curtains drift. */\n speed?: number;\n /** How many curtains hang, 2 to 4. */\n curtains?: number;\n};\n\nexport const GelWash = ({colors, intensity = 0.55, speed = 1, curtains = 3}: GelWashProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {fps, width, height} = useVideo();\n const palette = colors?.length ? colors : [brand.colors.accent, brand.colors.muted, brand.colors.surface];\n const count = Math.max(2, Math.min(4, Math.round(curtains)));\n const t = (frame / fps) * speed;\n\n return (\n <Fill style={{background: brand.colors.background, overflow: \"hidden\"}}>\n {Array.from({length: count}, (_, index) => {\n // Everything a curtain does differently, it takes from its seed, so\n // the arrangement is fixed however many times this renders.\n const rate = randomBetween(`curtain-${index}-rate`, 0.05, 0.11);\n const phase = random(`curtain-${index}-phase`) * Math.PI * 2;\n const tilt = randomBetween(`curtain-${index}-tilt`, -24, 24);\n const anchor = randomBetween(`curtain-${index}-x`, 15, 85);\n\n const sway = Math.sin(t * rate * Math.PI * 2 + phase) * width * 0.12;\n const rise = Math.cos(t * rate * 0.7 * Math.PI * 2 + phase * 1.7) * height * 0.06;\n const colour = palette[index % palette.length];\n\n return (\n <div\n key={index}\n aria-hidden\n style={{\n background: `linear-gradient(180deg, transparent 0%, ${colour} 45%, transparent 100%)`,\n filter: `blur(${90 * scale}px)`,\n height: height * 1.6,\n left: `calc(${anchor}% - ${180 * scale}px)`,\n opacity: intensity * (1 - index * 0.18),\n position: \"absolute\",\n top: -height * 0.3,\n transform: `translate(${sway}px, ${rise}px) rotate(${tilt}deg) skewX(${tilt * 0.5}deg)`,\n width: 360 * scale,\n }}\n />\n );\n })}\n </Fill>\n );\n};\n",
|
|
2604
|
+
"target": "videos/components/gel-wash/gel-wash.tsx"
|
|
2605
|
+
},
|
|
2606
|
+
{
|
|
2607
|
+
"path": "components/gel-wash/gel-wash.preview.tsx",
|
|
2608
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {GelWash} from \"./gel-wash\";\n\nexport default defineComponentPreview({\n title: \"Gel wash\",\n category: \"Backgrounds\",\n description: \"Blurred, tilted colour curtains drifting on independent sine paths over a dark ground.\",\n component: GelWash,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n intensity: {type: \"number\", defaultValue: 0.55, min: 0, max: 1, step: 0.05},\n speed: {type: \"number\", defaultValue: 1, min: 0, max: 3, step: 0.1},\n curtains: {type: \"number\", defaultValue: 3, min: 2, max: 4, step: 1},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Polar\", props: {colors: [\"#3ddc97\", \"#4f8fdd\", \"#7a5fd0\"], intensity: 0.45}},\n {name: \"Faint\", props: {curtains: 2, intensity: 0.15, speed: 0.6}},\n ],\n});\n",
|
|
2609
|
+
"target": "videos/components/gel-wash/gel-wash.preview.tsx"
|
|
2610
|
+
}
|
|
2611
|
+
],
|
|
2612
|
+
"meta": {
|
|
2613
|
+
"kind": "component",
|
|
2614
|
+
"family": "Backgrounds",
|
|
2615
|
+
"namespaced": "@odori/gel-wash",
|
|
2616
|
+
"contract": {
|
|
2617
|
+
"aspectRatios": [
|
|
2618
|
+
"16:9",
|
|
2619
|
+
"9:16",
|
|
2620
|
+
"1:1"
|
|
2621
|
+
],
|
|
2622
|
+
"recommendedDurationInFrames": 180,
|
|
2623
|
+
"minimumDurationInFrames": 30,
|
|
2624
|
+
"entranceFrames": 0,
|
|
2625
|
+
"exitFrames": 0,
|
|
2626
|
+
"contentLimits": {},
|
|
2627
|
+
"reducedMotion": "set speed to 0 and the curtains hang still in their seeded positions",
|
|
2628
|
+
"requires": {
|
|
2629
|
+
"fonts": [],
|
|
2630
|
+
"audio": []
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
},
|
|
2222
2635
|
{
|
|
2223
2636
|
"name": "github-comment",
|
|
2224
2637
|
"description": "An agent answering on a pull request, on the page the answer appears on.",
|
|
@@ -2226,7 +2639,7 @@
|
|
|
2226
2639
|
"files": [
|
|
2227
2640
|
{
|
|
2228
2641
|
"path": "components/github-comment/github-comment.tsx",
|
|
2229
|
-
"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 theme?: \"light\" | \"dimmed\";\n charactersPerSecond?: number;\n};\n\n/** Primer's dark tokens, as GitHub ships them. */\n/**\n * GitHub's palettes, read out of its own custom properties.\n *\n * \"dimmed\" is that theme exactly, and it is what this component used to be\n * hard-coded to; light is what GitHub opens as, and there was no way to ask\n * for it at all.\n */\nconst PALETTE = {\n light: {\n header: \"#F6F8FA\",\n canvas: \"#FFFFFF\",\n inset: \"#F6F8FA\",\n edge: \"#D1D9E0\",\n ink: \"#1F2328\",\n muted: \"#59636E\",\n link: \"#0969DA\",\n open: \"#1F883D\",\n bot: \"#8250DF\",\n },\n dimmed: {\n header: \"#151B23\",\n canvas: \"#212830\",\n inset: \"#262C36\",\n edge: \"#3D444D\",\n ink: \"#D1D7E0\",\n muted: \"#9198A1\",\n link: \"#478BE6\",\n open: \"#347D39\",\n bot: \"#8256D0\",\n },\n};\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 theme = \"light\",\n charactersPerSecond = 34,\n}: GithubCommentProps) => {\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 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 ? colors.bot : colors.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: colors.inset,\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n display: \"flex\",\n gap: px(10),\n padding: `${px(12)}px ${px(16)}px`,\n }}\n >\n {avatar(who.name, who.bot ? colors.bot : null, 30)}\n <span style={{color: colors.ink, fontSize: px(19), fontWeight: 600}}>{who.name}</span>\n {who.bot ? (\n <span\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n color: colors.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: colors.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: colors.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 ${colors.edge}`, padding: `${px(18)}px 0`}}>\n <div style={{color: colors.muted, fontSize: px(17), fontWeight: 600, marginBottom: px(10)}}>{heading}</div>\n {body}\n </div>\n );\n\n return (\n <Fill style={{background: theme === \"light\" ? \"#E7EAEE\" : \"#010409\", padding: px(56)}}>\n <div\n style={{\n background: colors.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: colors.header, borderBottom: `${px(1)}px solid ${colors.edge}`}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(16), padding: `${px(14)}px ${px(22)}px`}}>\n {identicon(owner, 30, px)}\n <span style={{color: colors.ink, fontSize: px(19)}}>\n <span style={{color: colors.link}}>{owner}</span>\n <span style={{color: colors.muted}}> / </span>\n <span style={{color: colors.link, fontWeight: 600}}>{repo}</span>\n </span>\n <span\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(7),\n color: colors.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\" ? colors.ink : colors.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: colors.ink, fontSize: px(32), fontWeight: 400, letterSpacing: \"-0.01em\"}}>\n {title} <span style={{color: colors.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: colors.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: colors.muted, fontSize: px(18)}}>\n <span style={{color: colors.ink}}>{request?.author}</span> wants to merge 3 commits into{\" \"}\n <span style={{background: colors.inset, borderRadius: px(5), color: colors.ink, fontFamily: brand.typography.mono, padding: `${px(2)}px ${px(7)}px`}}>\n {base}\n </span>{\" \"}\n from{\" \"}\n <span style={{background: colors.inset, borderRadius: px(5), color: colors.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 ${colors.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\" ? colors.ink : colors.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: colors.inset, borderRadius: px(999), color: colors.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: colors.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: colors.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 ${colors.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: colors.inset, borderBottom: `${px(1)}px solid ${colors.edge}`, color: colors.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: colors.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: colors.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 ${colors.edge}`,\n color: colors.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={colors.bot} />\n <path d=\"M7.5 12.2l3 3 6-6.4\" fill=\"none\" stroke={colors.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 ${colors.edge}`,\n borderRadius: px(10),\n marginTop: px(16),\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n background: colors.inset,\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n color: colors.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 ? colors.muted : colors.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: colors.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: colors.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: colors.ink, display: \"flex\", fontSize: px(18), gap: px(10)}}>\n {avatar(agent?.name ?? \"odori\", colors.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 ${colors.edge}`,\n borderRadius: px(999),\n color: colors.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",
|
|
2642
|
+
"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 theme?: \"light\" | \"dimmed\";\n charactersPerSecond?: number;\n};\n\n/** Primer's dark tokens, as GitHub ships them. */\n/**\n * GitHub's palettes, read out of its own custom properties.\n *\n * \"dimmed\" is that theme exactly, and it is what this component used to be\n * hard-coded to; light is what GitHub opens as, and there was no way to ask\n * for it at all.\n */\nconst PALETTE = {\n light: {\n header: \"#F6F8FA\",\n canvas: \"#FFFFFF\",\n inset: \"#F6F8FA\",\n edge: \"#D1D9E0\",\n ink: \"#1F2328\",\n muted: \"#59636E\",\n link: \"#0969DA\",\n open: \"#1F883D\",\n bot: \"#8250DF\",\n },\n dimmed: {\n header: \"#151B23\",\n canvas: \"#212830\",\n inset: \"#262C36\",\n edge: \"#3D444D\",\n ink: \"#D1D7E0\",\n muted: \"#9198A1\",\n link: \"#478BE6\",\n open: \"#347D39\",\n bot: \"#8256D0\",\n },\n};\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 theme = \"light\",\n charactersPerSecond = 34,\n}: GithubCommentProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const colors = PALETTE[theme];\n /* Set at the zoom a screen capture is shown at, not at the browser's own\n point sizes: 17px body text is legible on a laptop and a caption strip in\n a video frame. The page crops at the card's edge, the way a capture does. */\n const px = (value: number) => value * scale * 1.38;\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 ? colors.bot : colors.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: colors.inset,\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n display: \"flex\",\n gap: px(10),\n padding: `${px(12)}px ${px(16)}px`,\n }}\n >\n {avatar(who.name, who.bot ? colors.bot : null, 30)}\n <span style={{color: colors.ink, fontSize: px(19), fontWeight: 600}}>{who.name}</span>\n {who.bot ? (\n <span\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n color: colors.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: colors.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: colors.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 ${colors.edge}`, padding: `${px(18)}px 0`}}>\n <div style={{color: colors.muted, fontSize: px(17), fontWeight: 600, marginBottom: px(10)}}>{heading}</div>\n {body}\n </div>\n );\n\n return (\n <Fill style={{background: theme === \"light\" ? \"#E7EAEE\" : \"#010409\", padding: px(34)}}>\n <div\n style={{\n background: colors.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: colors.header, borderBottom: `${px(1)}px solid ${colors.edge}`}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(16), padding: `${px(14)}px ${px(22)}px`}}>\n {identicon(owner, 30, px)}\n <span style={{color: colors.ink, fontSize: px(19)}}>\n <span style={{color: colors.link}}>{owner}</span>\n <span style={{color: colors.muted}}> / </span>\n <span style={{color: colors.link, fontWeight: 600}}>{repo}</span>\n </span>\n <span\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(7),\n color: colors.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\" ? colors.ink : colors.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: colors.ink, fontSize: px(32), fontWeight: 400, letterSpacing: \"-0.01em\"}}>\n {title} <span style={{color: colors.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: colors.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: colors.muted, fontSize: px(18)}}>\n <span style={{color: colors.ink}}>{request?.author}</span> wants to merge 3 commits into{\" \"}\n <span style={{background: colors.inset, borderRadius: px(5), color: colors.ink, fontFamily: brand.typography.mono, padding: `${px(2)}px ${px(7)}px`}}>\n {base}\n </span>{\" \"}\n from{\" \"}\n <span style={{background: colors.inset, borderRadius: px(5), color: colors.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 ${colors.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\" ? colors.ink : colors.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: colors.inset, borderRadius: px(999), color: colors.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: colors.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: colors.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 ${colors.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: colors.inset, borderBottom: `${px(1)}px solid ${colors.edge}`, color: colors.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: colors.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: colors.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 ${colors.edge}`,\n color: colors.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={colors.bot} />\n <path d=\"M7.5 12.2l3 3 6-6.4\" fill=\"none\" stroke={colors.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 ${colors.edge}`,\n borderRadius: px(10),\n marginTop: px(16),\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n background: colors.inset,\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n color: colors.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 ? colors.muted : colors.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: colors.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: colors.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: colors.ink, display: \"flex\", fontSize: px(18), gap: px(10)}}>\n {avatar(agent?.name ?? \"odori\", colors.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 ${colors.edge}`,\n borderRadius: px(999),\n color: colors.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",
|
|
2230
2643
|
"target": "videos/components/github-comment/github-comment.tsx"
|
|
2231
2644
|
},
|
|
2232
2645
|
{
|
|
@@ -2273,7 +2686,7 @@
|
|
|
2273
2686
|
"files": [
|
|
2274
2687
|
{
|
|
2275
2688
|
"path": "components/github-pr/github-pr.tsx",
|
|
2276
|
-
"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?: \"light\" | \"dimmed\";\n};\n\nconst PALETTE = {\n light: {page: \"#FFFFFF\", card: \"#F6F8FA\", edge: \"#D1D9E0\", ink: \"#1F2328\", faint: \"#59636E\", open: \"#1F883D\"},\n /* Named for what it is: these are GitHub's dark-dimmed values, not its\n default dark, and calling it \"dark\" set the wrong expectation. */\n dimmed: {page: \"#212830\", card: \"#262C36\", edge: \"#3D444D\", ink: \"#D1D7E0\", faint: \"#9198A1\", open: \"#347D39\"},\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 = \"light\",\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",
|
|
2689
|
+
"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 /** Commits, as the byline states them. */\n commits?: number;\n /** Files and lines, as the summary under the byline 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?: \"light\" | \"dimmed\";\n};\n\nconst PALETTE = {\n light: {\n page: \"#FFFFFF\",\n card: \"#F6F8FA\",\n edge: \"#D1D9E0\",\n ink: \"#1F2328\",\n faint: \"#59636E\",\n open: \"#1F883D\",\n chip: \"#DDF4FF\",\n chipInk: \"#0969DA\",\n },\n /* Named for what it is: these are GitHub's dark-dimmed values, not its\n default dark, and calling it \"dark\" set the wrong expectation. */\n dimmed: {\n page: \"#212830\",\n card: \"#262C36\",\n edge: \"#3D444D\",\n ink: \"#D1D7E0\",\n faint: \"#9198A1\",\n open: \"#347D39\",\n chip: \"#143D79\",\n chipInk: \"#478BE6\",\n },\n};\n\nconst FONT = '\"Mona Sans VF\", \"Mona Sans\", -apple-system, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif';\nconst GREEN = \"#1A7F37\";\nconst SUCCESS = \"#3FB950\";\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 *\n * Set at the zoom a screen capture would be shown at, not at the browser's\n * own point sizes: text that is comfortable to read on a laptop is a caption\n * strip in a video frame, and this is a frame first.\n */\nexport const GithubPr = ({\n title,\n number = 128,\n author = \"johndoe\",\n branch = \"feat/agent-surfaces\",\n base = \"main\",\n commits = 3,\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 = \"light\",\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 /* Springs overshoot, and a color-mix percentage past 100 invalidates the\n whole declaration; the fill uses the clamped value, the motion the raw. */\n const fill = Math.min(1, Math.max(0, merge));\n const allDone = frame >= settled;\n\n const chip = (name: string) => (\n <span\n style={{\n background: colors.chip,\n borderRadius: px(7),\n color: colors.chipInk,\n fontFamily: brand.typography.mono,\n fontSize: px(24),\n padding: `${px(3)}px ${px(10)}px`,\n }}\n >\n {name}\n </span>\n );\n\n /** The status octicon: a spinner that hands over to a filled green check. */\n const status = (done: number) => (\n <span\n style={{\n alignItems: \"center\",\n display: \"inline-flex\",\n height: px(36),\n justifyContent: \"center\",\n position: \"relative\",\n width: px(36),\n }}\n >\n <span\n style={{\n border: `${px(3.5)}px solid #D4A72C`,\n borderRadius: px(999),\n borderTopColor: \"transparent\",\n height: px(26),\n opacity: 1 - done,\n position: \"absolute\",\n transform: `rotate(${frame * 12}deg)`,\n width: px(26),\n }}\n />\n <svg\n viewBox=\"0 0 24 24\"\n width={px(32)}\n height={px(32)}\n style={{opacity: done, position: \"absolute\", transform: `scale(${0.6 + done * 0.4})`}}\n >\n <circle cx=\"12\" cy=\"12\" r=\"10\" fill={SUCCESS} />\n <path\n d=\"M7.5 12.2l3 3 6-6.4\"\n fill=\"none\"\n stroke={theme === \"light\" ? \"#FFFFFF\" : \"#0D1117\"}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.6}\n />\n </svg>\n </span>\n );\n\n return (\n <Fill style={{alignItems: \"center\", background: colors.page, justifyContent: \"center\", padding: `${px(70)}px ${px(120)}px`}}>\n <div\n style={{\n fontFamily: FONT,\n maxWidth: px(1460),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(16)}px)`,\n width: \"100%\",\n }}\n >\n {/* The title is regular weight with the number in grey, which is how\n GitHub actually sets it; bolding it reads as a mock. */}\n <div style={{color: colors.ink, fontSize: px(58), fontWeight: 400, letterSpacing: \"-0.01em\", lineHeight: 1.2}}>\n {title} <span style={{color: colors.faint}}>#{number}</span>\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", flexWrap: \"wrap\", gap: px(14), marginTop: px(22)}}>\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(26),\n fontWeight: 600,\n gap: px(9),\n padding: `${px(9)}px ${px(11)}px ${px(9)}px ${px(11)}px`,\n }}\n >\n <svg viewBox=\"0 0 16 16\" width={px(26)} height={px(26)} fill=\"#FFFFFF\" aria-hidden>\n <path d=\"M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354Z\" />\n </svg>\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, 30, px)}\n <span style={{color: colors.faint, fontSize: px(27)}}>\n <span style={{color: colors.ink, fontWeight: 600}}>{author}</span> wants to merge {commits} commit\n {commits === 1 ? \"\" : \"s\"} into {chip(base)} from {chip(branch)}\n </span>\n </div>\n\n <div style={{color: colors.faint, display: \"flex\", fontSize: px(24), gap: px(20), marginTop: px(18)}}>\n <span>\n <span style={{color: colors.ink, fontWeight: 600}}>{stats.files}</span> files changed\n </span>\n <span style={{color: SUCCESS, fontWeight: 600}}>+{stats.additions}</span>\n <span style={{color: \"#F85149\", fontWeight: 600}}>-{stats.deletions}</span>\n </div>\n\n {/* The merge box, as the bottom of the conversation draws it. */}\n <div\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(10),\n marginTop: px(36),\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n background: colors.card,\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n display: \"flex\",\n gap: px(16),\n padding: `${px(22)}px ${px(26)}px`,\n }}\n >\n {status(merge)}\n <div>\n <div style={{color: colors.ink, fontSize: px(28), fontWeight: 600}}>\n {allDone ? \"All checks have passed\" : \"Some checks haven’t completed yet\"}\n </div>\n <div style={{color: colors.faint, fontSize: px(22), marginTop: px(2)}}>\n {checks.length} check{checks.length === 1 ? \"\" : \"s\"}\n {allDone ? \" successful\" : \" running\"}\n </div>\n </div>\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 const finished = frame >= CHECK_START + index * CHECK_STEP;\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(17)}px ${px(26)}px`,\n }}\n >\n {status(done)}\n <span style={{color: colors.ink, fontSize: px(26), fontWeight: 600}}>{check.name}</span>\n <span style={{color: colors.faint, fontSize: px(24)}}>\n {finished ? `Successful${check.detail ? ` in ${check.detail}` : \"\"}` : \"In progress…\"}\n </span>\n <span style={{color: colors.faint, fontSize: px(24), marginLeft: \"auto\"}}>Details</span>\n </div>\n );\n })}\n </div>\n\n <div\n style={{\n alignItems: \"center\",\n background: colors.card,\n borderTop: `${px(1)}px solid ${colors.edge}`,\n display: \"flex\",\n gap: px(20),\n padding: `${px(22)}px ${px(26)}px`,\n }}\n >\n {/* GitHub's split button: the action, a seam, then the caret. It\n fills from disabled grey to its green the moment the last\n check lands, which is the frame the cut is waiting for. */}\n <span\n style={{\n background: `color-mix(in srgb, ${GREEN} ${fill * 100}%, ${colors.card})`,\n border: `${px(1)}px solid color-mix(in srgb, rgb(31 35 40 / 0.15) ${fill * 100}%, ${colors.edge})`,\n borderRadius: px(8),\n color: `color-mix(in srgb, #FFFFFF ${fill * 100}%, ${colors.faint})`,\n display: \"inline-flex\",\n fontSize: px(26),\n fontWeight: 600,\n overflow: \"hidden\",\n }}\n >\n <span style={{padding: `${px(13)}px ${px(24)}px`}}>Merge pull request</span>\n <span\n style={{\n alignItems: \"center\",\n borderLeft: `${px(1)}px solid color-mix(in srgb, rgb(255 255 255 / 0.4) ${fill * 100}%, ${colors.edge})`,\n display: \"inline-flex\",\n padding: `0 ${px(14)}px`,\n }}\n >\n <svg viewBox=\"0 0 16 16\" width={px(20)} height={px(20)} fill=\"currentColor\" aria-hidden>\n <path d=\"M4.25 6l3.75 3.75L11.75 6\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </span>\n </span>\n <span style={{color: colors.faint, fontSize: px(23), opacity: fill}}>\n Merging can be performed automatically.\n </span>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
|
|
2277
2690
|
"target": "videos/components/github-pr/github-pr.tsx"
|
|
2278
2691
|
},
|
|
2279
2692
|
{
|
|
@@ -2312,39 +2725,37 @@
|
|
|
2312
2725
|
}
|
|
2313
2726
|
},
|
|
2314
2727
|
{
|
|
2315
|
-
"name": "
|
|
2316
|
-
"description": "
|
|
2728
|
+
"name": "glare-sweep",
|
|
2729
|
+
"description": "One specular sweep across a card or screenshot, scripted to cross exactly once.",
|
|
2317
2730
|
"registryDependencies": [],
|
|
2318
2731
|
"files": [
|
|
2319
2732
|
{
|
|
2320
|
-
"path": "components/
|
|
2321
|
-
"content": "import {Fill,
|
|
2322
|
-
"target": "videos/components/
|
|
2733
|
+
"path": "components/glare-sweep/glare-sweep.tsx",
|
|
2734
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useDesignScale, useFrame} from \"odori\";\n\nexport type GlareSweepProps = {\n /** The card or screenshot the sweep passes over. */\n children?: ReactNode;\n /** Frame the sweep begins. */\n at?: number;\n /** How long the travel takes, edge to edge. */\n sweepFrames?: number;\n /** Slant of the band in degrees. */\n angle?: number;\n /** Band width in design pixels. */\n width?: number;\n /** Peak opacity of the highlight. */\n strength?: number;\n};\n\n/**\n * One specular sweep across a card, and only one.\n *\n * The looping shine is a web-page habit; in a cut it keeps pulling the eye\n * back to something that already made its point. This one is scripted: the\n * band crosses once between `at` and `at + sweepFrames` and never returns, so\n * it lands like light catching an edge rather than like an advert. Opacity\n * eases in and out at the ends of the travel, because a band that pops into\n * existence at the border reads as a glitch, not a glint.\n */\nexport const GlareSweep = ({\n children,\n at = 0,\n sweepFrames = 26,\n angle = 14,\n width = 180,\n strength = 0.35,\n}: GlareSweepProps) => {\n const frame = useFrame();\n const scale = useDesignScale();\n\n const band = Math.max(24, width * scale);\n /* From fully off one edge to fully off the other. interpolate clamps, so\n after the travel the band parks off-screen and stays there. */\n const travel = interpolate(frame, [at, at + sweepFrames], [-30, 130], {easing: Easing.standard});\n const fade = interpolate(\n frame,\n [at, at + sweepFrames * 0.2, at + sweepFrames * 0.8, at + sweepFrames],\n [0, 1, 1, 0],\n );\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\"}}>\n <div style={{maxHeight: \"100%\", maxWidth: \"100%\", position: \"relative\"}}>\n {children}\n <div aria-hidden style={{inset: 0, overflow: \"hidden\", pointerEvents: \"none\", position: \"absolute\"}}>\n <div\n style={{\n background: `linear-gradient(90deg, transparent, rgba(255, 255, 255, ${strength}), transparent)`,\n bottom: \"-25%\",\n left: `calc(${travel}% - ${band / 2}px)`,\n opacity: fade,\n position: \"absolute\",\n top: \"-25%\",\n transform: `skewX(${-angle}deg)`,\n width: band,\n }}\n />\n </div>\n </div>\n </Fill>\n );\n};\n",
|
|
2735
|
+
"target": "videos/components/glare-sweep/glare-sweep.tsx"
|
|
2323
2736
|
},
|
|
2324
2737
|
{
|
|
2325
|
-
"path": "components/
|
|
2326
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {
|
|
2327
|
-
"target": "videos/components/
|
|
2738
|
+
"path": "components/glare-sweep/glare-sweep.preview.tsx",
|
|
2739
|
+
"content": "import {useBrand} from \"odori\";\nimport {defineComponentPreview} from \"odori/preview\";\nimport {GlareSweep} from \"./glare-sweep\";\n\nconst Card = () => {\n const brand = useBrand();\n return (\n <div\n style={{\n alignItems: \"center\",\n background: brand.colors.surface,\n border: `2px solid ${brand.colors.border}`,\n borderRadius: 24,\n color: brand.colors.foreground,\n display: \"flex\",\n fontFamily: brand.typography.sans,\n fontSize: 64,\n fontWeight: 600,\n height: 480,\n justifyContent: \"center\",\n letterSpacing: \"-0.03em\",\n overflow: \"hidden\",\n width: 960,\n }}\n >\n v2.0 — out today\n </div>\n );\n};\n\nexport default defineComponentPreview({\n title: \"Glare sweep\",\n category: \"Media/Treatments\",\n description: \"One specular sweep across a card or screenshot, scripted to cross exactly once.\",\n component: GlareSweep,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n at: {type: \"number\", defaultValue: 20, min: 0, max: 80},\n sweepFrames: {type: \"number\", defaultValue: 26, min: 8, max: 90},\n angle: {type: \"number\", defaultValue: 14, min: -45, max: 45, step: 1},\n width: {type: \"number\", defaultValue: 180, min: 40, max: 600, step: 10},\n strength: {type: \"number\", defaultValue: 0.35, min: 0.05, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Default\", props: {children: <Card />, at: 20}},\n {name: \"Wide and soft\", props: {children: <Card />, at: 20, width: 420, strength: 0.2, sweepFrames: 40}},\n {name: \"Hard glint\", props: {children: <Card />, at: 30, width: 80, strength: 0.6, angle: 24, sweepFrames: 14}},\n ],\n});\n",
|
|
2740
|
+
"target": "videos/components/glare-sweep/glare-sweep.preview.tsx"
|
|
2328
2741
|
}
|
|
2329
2742
|
],
|
|
2330
2743
|
"meta": {
|
|
2331
2744
|
"kind": "component",
|
|
2332
|
-
"family": "
|
|
2333
|
-
"namespaced": "@odori/
|
|
2745
|
+
"family": "Media",
|
|
2746
|
+
"namespaced": "@odori/glare-sweep",
|
|
2334
2747
|
"contract": {
|
|
2335
2748
|
"aspectRatios": [
|
|
2336
2749
|
"16:9",
|
|
2337
2750
|
"9:16",
|
|
2338
2751
|
"1:1"
|
|
2339
2752
|
],
|
|
2340
|
-
"recommendedDurationInFrames":
|
|
2341
|
-
"minimumDurationInFrames":
|
|
2753
|
+
"recommendedDurationInFrames": 90,
|
|
2754
|
+
"minimumDurationInFrames": 40,
|
|
2342
2755
|
"entranceFrames": 0,
|
|
2343
2756
|
"exitFrames": 0,
|
|
2344
|
-
"contentLimits": {
|
|
2345
|
-
|
|
2346
|
-
},
|
|
2347
|
-
"reducedMotion": "the field holds one still position",
|
|
2757
|
+
"contentLimits": {},
|
|
2758
|
+
"reducedMotion": "set strength to 0 and the sweep never appears",
|
|
2348
2759
|
"requires": {
|
|
2349
2760
|
"fonts": [],
|
|
2350
2761
|
"audio": []
|
|
@@ -2353,70 +2764,67 @@
|
|
|
2353
2764
|
}
|
|
2354
2765
|
},
|
|
2355
2766
|
{
|
|
2356
|
-
"name": "
|
|
2357
|
-
"description": "
|
|
2767
|
+
"name": "glass-caustics",
|
|
2768
|
+
"description": "The net of light on the bottom of a pool, from a plane folded through itself three times.",
|
|
2358
2769
|
"registryDependencies": [],
|
|
2359
2770
|
"files": [
|
|
2360
2771
|
{
|
|
2361
|
-
"path": "components/
|
|
2362
|
-
"content": "import {
|
|
2363
|
-
"target": "videos/components/
|
|
2772
|
+
"path": "components/glass-caustics/glass-caustics.tsx",
|
|
2773
|
+
"content": "import {Fill, useBrand, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * The net of light thrown on the bottom of a pool.\n *\n * A caustic is where a curved surface folds light onto itself, so the bright\n * parts are thin, closed and always moving, and the dark parts are most of the\n * frame. Getting there is a domain warp: the plane is pushed through itself\n * three times, then the bright net is the place where three sines nearly\n * cancel. Fewer folds gives ripples on a paddling pool, more gives cut glass.\n *\n * `sharpness` is the control worth reaching for. Low is a soft wash that will\n * sit under body text; high is a hard net that wants to be the whole shot.\n */\nexport type GlassCausticsProps = {\n /** Light and water. Defaults to the brand's foreground on its background. */\n colors?: [string, string];\n /** Size of the net. Higher is finer. */\n scale?: number;\n /** How fast the surface moves. */\n speed?: number;\n /** How thin and bright the lines are. */\n sharpness?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\n/**\n * The brand's darkest and lightest tokens, in that order.\n *\n * Light is bright and water is dark whichever way the brand runs, so taking\n * `foreground` for the net drew black lines on white under a light brand —\n * a photographic negative of the thing being described. Asking which token is\n * darker rather than which one is named background is what makes one default\n * right in both themes.\n */\nconst tones = (background: string, foreground: string): [string, string] => {\n const luminance = (hex: string): number => {\n const [r, g, b] = hexToRgb(hex);\n return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n };\n return luminance(background) <= luminance(foreground)\n ? [background, foreground]\n : [foreground, background];\n};\n\nconst caustics = defineShaderEffect({\n name: \"glass-caustics\",\n uniforms: {\n scale: numberUniform(6),\n speed: numberUniform(1),\n sharpness: numberUniform(3.2),\n light: vec3Uniform([1, 1, 1]),\n ground: vec3Uniform([0, 0, 0]),\n },\n fragmentShader: `\nuniform float scale;\nuniform float speed;\nuniform float sharpness;\nuniform vec3 light;\nuniform vec3 ground;\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n uv.x *= resolution.x / resolution.y;\n float t = seconds * speed;\n\n /* Three folds. Each one pushes the plane through a sine of the other axis,\n which is what turns a regular grid of ripples into a net that never quite\n repeats inside a shot. */\n vec2 p = uv * max(0.5, scale);\n for (int i = 0; i < 3; i++) {\n p += vec2(sin(p.y * 1.5 + t * 0.7), cos(p.x * 1.3 - t * 0.6)) * 0.55;\n }\n\n /* The net is the zero set of the three sines: bright exactly where they\n cancel, dark everywhere else. The exponent has to be steep — at a gentle\n one the bright region is most of the frame and the result reads as poured\n ink rather than as light, because a caustic is thin by definition. */\n float v = abs(sin(p.x) + sin(p.y) + sin(p.x + p.y)) / 3.0;\n float net = pow(clamp(1.0 - v, 0.0, 1.0), max(1.0, sharpness) * 4.5);\n\n /* A far weaker wash underneath, so the water has a floor between the lines\n without becoming a second, blurrier net competing with the first. */\n float wash = pow(clamp(1.0 - v, 0.0, 1.0), 3.0) * 0.12;\n\n odoriColour = vec4(mix(ground, light, clamp(net + wash, 0.0, 1.0)), 1.0);\n}`,\n});\n\nexport const GlassCaustics = ({colors, scale = 6, speed = 1, sharpness = 3.2}: GlassCausticsProps) => {\n const brand = useBrand();\n const {width, height} = useVideo();\n const [ground, light] = colors\n ? [colors[1], colors[0]]\n : tones(brand.colors.background, brand.colors.foreground);\n\n return (\n <EffectSurface\n effects={[caustics({scale, speed, sharpness, light: hexToRgb(light), ground: hexToRgb(ground)})]}\n >\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n",
|
|
2774
|
+
"target": "videos/components/glass-caustics/glass-caustics.tsx"
|
|
2364
2775
|
},
|
|
2365
2776
|
{
|
|
2366
|
-
"path": "components/
|
|
2367
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {
|
|
2368
|
-
"target": "videos/components/
|
|
2777
|
+
"path": "components/glass-caustics/glass-caustics.preview.tsx",
|
|
2778
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {GlassCaustics} from \"./glass-caustics\";\n\nexport default defineComponentPreview({\n title: \"Glass caustics\",\n category: \"Backgrounds\",\n description: \"The net of light on the bottom of a pool, from a plane folded through itself three times.\",\n component: GlassCaustics,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n scale: {type: \"number\", defaultValue: 6, min: 1, max: 14, step: 0.5},\n speed: {type: \"number\", defaultValue: 1, min: 0, max: 3, step: 0.1},\n sharpness: {type: \"number\", defaultValue: 3.2, min: 1, max: 8, step: 0.2},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Under text\", props: {sharpness: 1.4, scale: 4, speed: 0.5}},\n {name: \"Cut glass\", props: {sharpness: 6.5, scale: 8}},\n ],\n});\n",
|
|
2779
|
+
"target": "videos/components/glass-caustics/glass-caustics.preview.tsx"
|
|
2369
2780
|
}
|
|
2370
2781
|
],
|
|
2371
2782
|
"meta": {
|
|
2372
2783
|
"kind": "component",
|
|
2373
|
-
"family": "
|
|
2374
|
-
"namespaced": "@odori/
|
|
2784
|
+
"family": "Backgrounds",
|
|
2785
|
+
"namespaced": "@odori/glass-caustics",
|
|
2375
2786
|
"contract": {
|
|
2376
2787
|
"aspectRatios": [
|
|
2377
2788
|
"16:9",
|
|
2378
2789
|
"9:16",
|
|
2379
2790
|
"1:1"
|
|
2380
2791
|
],
|
|
2381
|
-
"recommendedDurationInFrames":
|
|
2382
|
-
"minimumDurationInFrames":
|
|
2383
|
-
"entranceFrames":
|
|
2384
|
-
"exitFrames":
|
|
2385
|
-
"contentLimits": {
|
|
2386
|
-
|
|
2387
|
-
"subtitle": 32
|
|
2388
|
-
},
|
|
2389
|
-
"reducedMotion": "dots shown at full size, no spread",
|
|
2792
|
+
"recommendedDurationInFrames": 270,
|
|
2793
|
+
"minimumDurationInFrames": 60,
|
|
2794
|
+
"entranceFrames": 0,
|
|
2795
|
+
"exitFrames": 0,
|
|
2796
|
+
"contentLimits": {},
|
|
2797
|
+
"reducedMotion": "set speed to 0 and the net holds one pattern",
|
|
2390
2798
|
"requires": {
|
|
2391
|
-
"fonts": [
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2799
|
+
"fonts": [],
|
|
2800
|
+
"audio": [],
|
|
2801
|
+
"graphics": [
|
|
2802
|
+
"webgl2"
|
|
2803
|
+
]
|
|
2396
2804
|
}
|
|
2397
2805
|
}
|
|
2398
2806
|
}
|
|
2399
2807
|
},
|
|
2400
2808
|
{
|
|
2401
|
-
"name": "
|
|
2402
|
-
"description": "
|
|
2809
|
+
"name": "glitch-text",
|
|
2810
|
+
"description": "A title that arrives through brief seeded interference bursts.",
|
|
2403
2811
|
"registryDependencies": [],
|
|
2404
2812
|
"files": [
|
|
2405
2813
|
{
|
|
2406
|
-
"path": "components/
|
|
2407
|
-
"content": "import {
|
|
2408
|
-
"target": "videos/components/
|
|
2814
|
+
"path": "components/glitch-text/glitch-text.tsx",
|
|
2815
|
+
"content": "import type {CSSProperties} from \"react\";\nimport {Easing, Fill, interpolate, random, randomBetween, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type GlitchTextProps = {\n text: string;\n detail?: string;\n /** Frames on which a burst begins. Two or three short ones read best. */\n bursts?: number[];\n /** How long each burst lasts. */\n burstFrames?: number;\n /** How far slices and the colour split are pushed, 1 being the design. */\n strength?: number;\n};\n\n/** Characters a glyph briefly becomes mid-burst. All near the title's width. */\nconst NOISE = \"#%&$/\\\\<>*+=@\";\n\n/**\n * A title that arrives through interference.\n *\n * The effect lives entirely inside the bursts: between them the text is\n * perfectly clean, which is what makes the corruption read as an event\n * rather than a style. Each burst hits at full intensity and decays, so it\n * feels like a signal being caught, and the last one leaves the title\n * settled for good — this never loops.\n *\n * All jitter comes from seeded random keyed to a two-frame step, so the\n * shudder is fast but stable within a step, and identical on every machine.\n */\nexport const GlitchText = ({text, detail, bursts = [8, 30], burstFrames = 6, strength = 1}: GlitchTextProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const step = Math.floor(frame / 2);\n let intensity = 0;\n for (const burst of bursts) {\n if (frame >= burst && frame < burst + burstFrames) {\n intensity = Math.max(intensity, 1 - (frame - burst) / burstFrames);\n }\n }\n const visible = bursts.length === 0 || frame >= Math.min(...bursts);\n const settled = bursts.length === 0 ? 0 : Math.max(...bursts) + burstFrames;\n const push = intensity * strength;\n\n const glyphs = [...text]\n .map((character, index) => {\n if (intensity <= 0 || character === \" \") return character;\n const swap = random([step, index, \"swap\"]) < intensity * 0.28;\n return swap ? NOISE[Math.floor(random([step, index, \"pick\"]) * NOISE.length)] : character;\n })\n .join(\"\");\n\n const line = (style: CSSProperties, key?: number) => (\n <div\n key={key}\n aria-hidden={style.position === \"absolute\"}\n style={{\n fontSize: 128 * scale,\n fontWeight: 650,\n letterSpacing: \"-0.03em\",\n lineHeight: 1.05,\n whiteSpace: \"pre\",\n ...style,\n }}\n >\n {glyphs}\n </div>\n );\n\n return (\n <Fill style={{alignItems: \"center\", gap: 28 * scale, justifyContent: \"center\"}}>\n <div style={{opacity: visible ? 1 : 0, position: \"relative\"}}>\n {/* The colour split sits under the clean copy at low opacity, so the\n title doubles without ever losing its own ink. */}\n {intensity > 0 ? (\n <>\n {line({\n color: brand.colors.muted,\n left: -randomBetween([step, \"split\"], 3, 7) * push * scale,\n opacity: 0.45 * intensity,\n position: \"absolute\",\n top: 0,\n })}\n {line({\n color: brand.colors.foreground,\n left: randomBetween([step, \"split\"], 3, 7) * push * scale,\n opacity: 0.45 * intensity,\n position: \"absolute\",\n top: 0,\n })}\n </>\n ) : null}\n {line({color: brand.colors.foreground, position: \"relative\"})}\n {/* Horizontal slices, each a clipped copy shoved a few pixels aside.\n Three bands are enough to read as a tear without shredding. */}\n {intensity > 0\n ? [0, 1, 2].map((band) => {\n const top = randomBetween([step, band, \"top\"], 5, 80);\n const depth = randomBetween([step, band, \"depth\"], 8, 20);\n return line(\n {\n clipPath: `inset(${top}% 0 ${Math.max(0, 100 - top - depth)}% 0)`,\n color: brand.colors.foreground,\n left: randomBetween([step, band, \"shove\"], -14, 14) * push * scale,\n position: \"absolute\",\n top: 0,\n },\n band,\n );\n })\n : null}\n </div>\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 34 * scale,\n opacity: interpolate(frame, [settled, settled + 16], [0, 1], {easing: Easing.standard}),\n }}\n >\n {detail}\n </div>\n ) : null}\n </Fill>\n );\n};\n",
|
|
2816
|
+
"target": "videos/components/glitch-text/glitch-text.tsx"
|
|
2409
2817
|
},
|
|
2410
2818
|
{
|
|
2411
|
-
"path": "components/
|
|
2412
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {
|
|
2413
|
-
"target": "videos/components/
|
|
2819
|
+
"path": "components/glitch-text/glitch-text.preview.tsx",
|
|
2820
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {GlitchText} from \"./glitch-text\";\n\nexport default defineComponentPreview({\n title: \"Glitch text\",\n category: \"Typography/Titles\",\n description: \"A title that arrives through brief seeded interference bursts.\",\n component: GlitchText,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"Signal\", maxLength: 24},\n detail: {type: \"text\", defaultValue: \"Locked on the first pass.\"},\n burstFrames: {type: \"number\", defaultValue: 6, min: 2, max: 14},\n strength: {type: \"number\", defaultValue: 1, min: 0.2, max: 2, step: 0.1},\n },\n examples: [\n {name: \"Default\", props: {text: \"Signal\", detail: \"Locked on the first pass.\"}},\n {name: \"Three bursts\", props: {text: \"Interference\", bursts: [6, 24, 44], burstFrames: 5}},\n {name: \"Gentle\", props: {text: \"Static\", strength: 0.5, bursts: [10, 36], burstFrames: 8}},\n ],\n});\n",
|
|
2821
|
+
"target": "videos/components/glitch-text/glitch-text.preview.tsx"
|
|
2414
2822
|
}
|
|
2415
2823
|
],
|
|
2416
2824
|
"meta": {
|
|
2417
2825
|
"kind": "component",
|
|
2418
|
-
"family": "
|
|
2419
|
-
"namespaced": "@odori/
|
|
2826
|
+
"family": "Typography",
|
|
2827
|
+
"namespaced": "@odori/glitch-text",
|
|
2420
2828
|
"contract": {
|
|
2421
2829
|
"aspectRatios": [
|
|
2422
2830
|
"16:9",
|
|
@@ -2425,13 +2833,13 @@
|
|
|
2425
2833
|
],
|
|
2426
2834
|
"recommendedDurationInFrames": 120,
|
|
2427
2835
|
"minimumDurationInFrames": 60,
|
|
2428
|
-
"entranceFrames":
|
|
2836
|
+
"entranceFrames": 36,
|
|
2429
2837
|
"exitFrames": 0,
|
|
2430
2838
|
"contentLimits": {
|
|
2431
|
-
"
|
|
2432
|
-
"
|
|
2839
|
+
"text": 24,
|
|
2840
|
+
"detail": 48
|
|
2433
2841
|
},
|
|
2434
|
-
"reducedMotion": "
|
|
2842
|
+
"reducedMotion": "the title fades in cleanly with no bursts",
|
|
2435
2843
|
"requires": {
|
|
2436
2844
|
"fonts": [
|
|
2437
2845
|
"sans"
|
|
@@ -2442,68 +2850,66 @@
|
|
|
2442
2850
|
}
|
|
2443
2851
|
},
|
|
2444
2852
|
{
|
|
2445
|
-
"name": "
|
|
2446
|
-
"description": "
|
|
2853
|
+
"name": "gradient-field",
|
|
2854
|
+
"description": "Subtle brand-aware background motion that loops seamlessly.",
|
|
2447
2855
|
"registryDependencies": [],
|
|
2448
2856
|
"files": [
|
|
2449
2857
|
{
|
|
2450
|
-
"path": "components/
|
|
2451
|
-
"content": "import {
|
|
2452
|
-
"target": "videos/components/
|
|
2858
|
+
"path": "components/gradient-field/gradient-field.tsx",
|
|
2859
|
+
"content": "import {Fill, useBrand, useFrame, useVideo} from \"odori\";\n\nexport type GradientFieldProps = {\n /** Colours to drift. Defaults to the brand's accent over its background. */\n colors?: string[];\n /** How far the blobs travel, as a fraction of the frame. */\n travel?: number;\n /** Frames for one full cycle. The motion repeats seamlessly across it. */\n cycleInFrames?: number;\n /** Blur radius in canvas pixels. Large is the point: this is atmosphere. */\n softness?: number;\n /** Overall strength, 0 to 1. */\n intensity?: number;\n};\n\n/**\n * Slow brand-aware colour behind everything else.\n *\n * The motion is driven by sine and cosine of the frame over one cycle, which\n * makes it exactly periodic: the last frame of a cycle matches the first, so\n * a background under a looping section never jumps. Deriving position from the\n * frame rather than from a CSS animation is also what keeps preview and export\n * showing the same pixels — a keyframe animation runs on wall time and would\n * be somewhere else in every render.\n */\nexport const GradientField = ({\n colors,\n travel = 0.18,\n cycleInFrames = 240,\n softness = 260,\n intensity = 0.55,\n}: GradientFieldProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const {width, height} = useVideo();\n const palette = colors ?? [brand.colors.accent, brand.colors.surface, brand.colors.accent];\n\n const turn = (frame / cycleInFrames) * Math.PI * 2;\n\n return (\n <Fill style={{background: brand.colors.background, overflow: \"hidden\"}}>\n {palette.map((color, index) => {\n // Each blob runs the same cycle at a different phase and a different\n // ratio, so they never line up and never drift out of period.\n const phase = turn + (index * Math.PI * 2) / palette.length;\n const x = 50 + Math.sin(phase) * travel * 100;\n const y = 50 + Math.cos(phase * (index % 2 === 0 ? 1 : -1.5)) * travel * 100;\n\n return (\n <div\n key={index}\n aria-hidden\n style={{\n background: `radial-gradient(circle at ${x}% ${y}%, ${color} 0%, transparent 60%)`,\n filter: `blur(${softness}px)`,\n inset: `-${softness}px`,\n opacity: intensity / palette.length + intensity * 0.35,\n position: \"absolute\",\n }}\n />\n );\n })}\n {/* A vignette keeps the corners from lifting, which is where a soft\n gradient otherwise starts to look like a rendering artefact. */}\n <div\n aria-hidden\n style={{\n background: `radial-gradient(circle at 50% 50%, transparent 40%, ${brand.colors.background} 100%)`,\n inset: 0,\n position: \"absolute\",\n }}\n />\n <div aria-hidden style={{height, position: \"absolute\", width, pointerEvents: \"none\"}} />\n </Fill>\n );\n};\n",
|
|
2860
|
+
"target": "videos/components/gradient-field/gradient-field.tsx"
|
|
2453
2861
|
},
|
|
2454
2862
|
{
|
|
2455
|
-
"path": "components/
|
|
2456
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {
|
|
2457
|
-
"target": "videos/components/
|
|
2863
|
+
"path": "components/gradient-field/gradient-field.preview.tsx",
|
|
2864
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {GradientField} from \"./gradient-field\";\n\nexport default defineComponentPreview({\n title: \"Gradient field\",\n category: \"Motion\",\n description: \"Subtle brand-aware background motion that loops seamlessly.\",\n component: GradientField,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n travel: {type: \"number\", defaultValue: 0.18, min: 0, max: 0.6, step: 0.02},\n cycleInFrames: {type: \"number\", defaultValue: 240, min: 60, max: 900},\n softness: {type: \"number\", defaultValue: 260, min: 40, max: 500},\n intensity: {type: \"number\", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Still\", props: {travel: 0}},\n {name: \"Strong\", props: {intensity: 0.9, travel: 0.32}},\n ],\n});\n",
|
|
2865
|
+
"target": "videos/components/gradient-field/gradient-field.preview.tsx"
|
|
2458
2866
|
}
|
|
2459
2867
|
],
|
|
2460
2868
|
"meta": {
|
|
2461
2869
|
"kind": "component",
|
|
2462
|
-
"family": "
|
|
2463
|
-
"namespaced": "@odori/
|
|
2870
|
+
"family": "Motion",
|
|
2871
|
+
"namespaced": "@odori/gradient-field",
|
|
2464
2872
|
"contract": {
|
|
2465
2873
|
"aspectRatios": [
|
|
2466
2874
|
"16:9",
|
|
2467
2875
|
"9:16",
|
|
2468
2876
|
"1:1"
|
|
2469
2877
|
],
|
|
2470
|
-
"recommendedDurationInFrames":
|
|
2471
|
-
"minimumDurationInFrames":
|
|
2472
|
-
"entranceFrames":
|
|
2878
|
+
"recommendedDurationInFrames": 240,
|
|
2879
|
+
"minimumDurationInFrames": 30,
|
|
2880
|
+
"entranceFrames": 0,
|
|
2473
2881
|
"exitFrames": 0,
|
|
2474
2882
|
"contentLimits": {
|
|
2475
|
-
"
|
|
2883
|
+
"colors": 5
|
|
2476
2884
|
},
|
|
2477
|
-
"reducedMotion": "the
|
|
2885
|
+
"reducedMotion": "the field holds one still position",
|
|
2478
2886
|
"requires": {
|
|
2479
|
-
"fonts": [
|
|
2480
|
-
"sans"
|
|
2481
|
-
],
|
|
2887
|
+
"fonts": [],
|
|
2482
2888
|
"audio": []
|
|
2483
2889
|
}
|
|
2484
2890
|
}
|
|
2485
2891
|
}
|
|
2486
2892
|
},
|
|
2487
2893
|
{
|
|
2488
|
-
"name": "
|
|
2489
|
-
"description": "A
|
|
2894
|
+
"name": "grid-pulse",
|
|
2895
|
+
"description": "A quiet dot or line grid with one soft wave of emphasis travelling the diagonal.",
|
|
2490
2896
|
"registryDependencies": [],
|
|
2491
2897
|
"files": [
|
|
2492
2898
|
{
|
|
2493
|
-
"path": "components/
|
|
2494
|
-
"content": "import {
|
|
2495
|
-
"target": "videos/components/
|
|
2899
|
+
"path": "components/grid-pulse/grid-pulse.tsx",
|
|
2900
|
+
"content": "import {Fill, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\n/**\n * A quiet grid with one soft wave of emphasis crossing it diagonally.\n *\n * A static grid is furniture; a grid where every cell pulses is noise. One\n * travelling wave is the amount of motion that reads as alive without asking\n * for attention: each cell brightens as the wave passes its diagonal and\n * settles back, and because the wave repeats along the diagonal there is\n * always exactly one crest somewhere in frame.\n *\n * The lines variant swaps dots for hairlines and pulses whole rows and\n * columns, which suits denser layouts where dots would fight with type.\n */\nexport type GridPulseProps = {\n /** Dots at the intersections, or the lines between them. */\n variant?: \"dots\" | \"lines\";\n /** Cell spacing, in design pixels. */\n gap?: number;\n /** Grid colour. Defaults to the brand's border. */\n color?: string;\n /** How fast the wave travels the diagonal. */\n speed?: number;\n /** How far a cell brightens above the resting grid. */\n intensity?: number;\n};\n\nexport const GridPulse = ({variant = \"dots\", gap = 72, color, speed = 1, intensity = 0.8}: GridPulseProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {fps, width, height} = useVideo();\n const ink = color ?? brand.colors.muted;\n const spacing = gap * scale;\n const columns = Math.ceil(width / spacing) + 1;\n const rows = Math.ceil(height / spacing) + 1;\n // The wave's position along the diagonal, in cells. Cosine of this minus a\n // cell's own diagonal is that cell's moment in the pulse.\n const travel = (frame / fps) * speed * 2.5;\n const pulseAt = (diagonal: number) => Math.pow(0.5 + 0.5 * Math.cos((diagonal - travel) * 0.9), 6);\n\n const resting = 0.14;\n\n return (\n <Fill style={{background: brand.colors.background}}>\n <svg aria-hidden height={height} width={width} style={{display: \"block\"}}>\n {variant === \"dots\"\n ? Array.from({length: columns * rows}, (_, index) => {\n const column = index % columns;\n const row = Math.floor(index / columns);\n const pulse = pulseAt(column + row);\n return (\n <circle\n key={index}\n cx={column * spacing}\n cy={row * spacing}\n fill={ink}\n opacity={resting + intensity * 0.5 * pulse}\n r={(2.5 + 1.5 * pulse * intensity) * scale}\n />\n );\n })\n : [\n ...Array.from({length: columns}, (_, column) => (\n <line\n key={`column-${column}`}\n opacity={resting + intensity * 0.4 * pulseAt(column)}\n stroke={ink}\n strokeWidth={scale}\n x1={column * spacing}\n x2={column * spacing}\n y1={0}\n y2={height}\n />\n )),\n ...Array.from({length: rows}, (_, row) => (\n <line\n key={`row-${row}`}\n opacity={resting + intensity * 0.4 * pulseAt(row)}\n stroke={ink}\n strokeWidth={scale}\n x1={0}\n x2={width}\n y1={row * spacing}\n y2={row * spacing}\n />\n )),\n ]}\n </svg>\n </Fill>\n );\n};\n",
|
|
2901
|
+
"target": "videos/components/grid-pulse/grid-pulse.tsx"
|
|
2496
2902
|
},
|
|
2497
2903
|
{
|
|
2498
|
-
"path": "components/
|
|
2499
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {
|
|
2500
|
-
"target": "videos/components/
|
|
2904
|
+
"path": "components/grid-pulse/grid-pulse.preview.tsx",
|
|
2905
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {GridPulse} from \"./grid-pulse\";\n\nexport default defineComponentPreview({\n title: \"Grid pulse\",\n category: \"Backgrounds\",\n description: \"A quiet dot or line grid with one soft wave of emphasis travelling the diagonal.\",\n component: GridPulse,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n variant: {type: \"select\", defaultValue: \"dots\", options: [\"dots\", \"lines\"]},\n speed: {type: \"number\", defaultValue: 1, min: 0, max: 3, step: 0.1},\n intensity: {type: \"number\", defaultValue: 0.8, min: 0, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Lines\", props: {variant: \"lines\", gap: 96}},\n {name: \"Fine\", props: {gap: 48, intensity: 0.3, speed: 0.7}},\n ],\n});\n",
|
|
2906
|
+
"target": "videos/components/grid-pulse/grid-pulse.preview.tsx"
|
|
2501
2907
|
}
|
|
2502
2908
|
],
|
|
2503
2909
|
"meta": {
|
|
2504
2910
|
"kind": "component",
|
|
2505
|
-
"family": "
|
|
2506
|
-
"namespaced": "@odori/
|
|
2911
|
+
"family": "Backgrounds",
|
|
2912
|
+
"namespaced": "@odori/grid-pulse",
|
|
2507
2913
|
"contract": {
|
|
2508
2914
|
"aspectRatios": [
|
|
2509
2915
|
"16:9",
|
|
@@ -2511,31 +2917,241 @@
|
|
|
2511
2917
|
"1:1"
|
|
2512
2918
|
],
|
|
2513
2919
|
"recommendedDurationInFrames": 180,
|
|
2514
|
-
"minimumDurationInFrames":
|
|
2515
|
-
"entranceFrames":
|
|
2920
|
+
"minimumDurationInFrames": 30,
|
|
2921
|
+
"entranceFrames": 0,
|
|
2516
2922
|
"exitFrames": 0,
|
|
2517
|
-
"contentLimits": {
|
|
2518
|
-
|
|
2519
|
-
},
|
|
2520
|
-
"reducedMotion": "every icon renders complete, with no draw on",
|
|
2923
|
+
"contentLimits": {},
|
|
2924
|
+
"reducedMotion": "set speed to 0 and the wave freezes into a still emphasis gradient across the grid",
|
|
2521
2925
|
"requires": {
|
|
2522
|
-
"fonts": [
|
|
2523
|
-
"sans"
|
|
2524
|
-
],
|
|
2926
|
+
"fonts": [],
|
|
2525
2927
|
"audio": []
|
|
2526
2928
|
}
|
|
2527
2929
|
}
|
|
2528
2930
|
}
|
|
2529
2931
|
},
|
|
2530
2932
|
{
|
|
2531
|
-
"name": "
|
|
2532
|
-
"description": "
|
|
2933
|
+
"name": "halftone-print",
|
|
2934
|
+
"description": "A word screened into halftone dots on a rotated grid, the ink spreading from the middle out.",
|
|
2533
2935
|
"registryDependencies": [],
|
|
2534
2936
|
"files": [
|
|
2535
2937
|
{
|
|
2536
|
-
"path": "components/
|
|
2537
|
-
"content": "import
|
|
2538
|
-
"target": "videos/components/
|
|
2938
|
+
"path": "components/halftone-print/halftone-print.tsx",
|
|
2939
|
+
"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",
|
|
2940
|
+
"target": "videos/components/halftone-print/halftone-print.tsx"
|
|
2941
|
+
},
|
|
2942
|
+
{
|
|
2943
|
+
"path": "components/halftone-print/halftone-print.preview.tsx",
|
|
2944
|
+
"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",
|
|
2945
|
+
"target": "videos/components/halftone-print/halftone-print.preview.tsx"
|
|
2946
|
+
}
|
|
2947
|
+
],
|
|
2948
|
+
"meta": {
|
|
2949
|
+
"kind": "component",
|
|
2950
|
+
"family": "Media",
|
|
2951
|
+
"namespaced": "@odori/halftone-print",
|
|
2952
|
+
"contract": {
|
|
2953
|
+
"aspectRatios": [
|
|
2954
|
+
"16:9",
|
|
2955
|
+
"9:16",
|
|
2956
|
+
"1:1"
|
|
2957
|
+
],
|
|
2958
|
+
"recommendedDurationInFrames": 180,
|
|
2959
|
+
"minimumDurationInFrames": 75,
|
|
2960
|
+
"entranceFrames": 12,
|
|
2961
|
+
"exitFrames": 10,
|
|
2962
|
+
"contentLimits": {
|
|
2963
|
+
"text": 16,
|
|
2964
|
+
"subtitle": 32
|
|
2965
|
+
},
|
|
2966
|
+
"reducedMotion": "dots shown at full size, no spread",
|
|
2967
|
+
"requires": {
|
|
2968
|
+
"fonts": [
|
|
2969
|
+
"sans",
|
|
2970
|
+
"mono"
|
|
2971
|
+
],
|
|
2972
|
+
"audio": []
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
},
|
|
2977
|
+
{
|
|
2978
|
+
"name": "halftone-reveal",
|
|
2979
|
+
"description": "Content arriving through a growing halftone dot screen, the transition twin of halftone print.",
|
|
2980
|
+
"registryDependencies": [],
|
|
2981
|
+
"files": [
|
|
2982
|
+
{
|
|
2983
|
+
"path": "components/halftone-reveal/halftone-reveal.tsx",
|
|
2984
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useDesignScale, useFrame, useVideo} from \"odori\";\n\nexport type HalftoneRevealProps = {\n /** What arrives through the dots. */\n children?: ReactNode;\n /** Frame the dots begin to grow. */\n at?: number;\n /** Frames until the dots have swallowed their cells. */\n durationInFrames?: number;\n /** Distance between dot centres, in design pixels. */\n pitch?: number;\n /** Screen angle in degrees, the way a print screen is rotated. */\n angle?: number;\n};\n\n/**\n * Children arriving through growing halftone dots.\n *\n * The transition twin of the halftone print: a repeating radial-gradient dot\n * screen is used as a mask on the content, and the dot radius animates from\n * nothing to past the point where neighbouring dots fuse, so the scene arrives\n * as ink filling a screen rather than as a fade. The screen is rotated like a\n * print screen — an unrotated one lines up with the pixel grid and reads as a\n * mistake — by rotating a masked wrapper and counter-rotating the content\n * inside it, which is the only way CSS lets a gradient tile sit at an angle.\n */\nexport const HalftoneReveal = ({\n children,\n at = 0,\n durationInFrames = 32,\n pitch = 18,\n angle = 15,\n}: HalftoneRevealProps) => {\n const frame = useFrame();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n\n const cell = Math.max(4, pitch * scale);\n const progress = interpolate(frame, [at, at + durationInFrames], [0, 1], {easing: Easing.standard});\n /* Dots cover their cell once the radius passes cell * √2 / 2, so 0.75 lands\n just beyond full bleed and the final frames are genuinely solid. */\n const dot = progress * cell * 0.75;\n /* The rotated wrapper must cover the frame's corners at any angle, and the\n diagonal is the size that does. */\n const bleed = Math.ceil(Math.hypot(width, height));\n const mask = `radial-gradient(circle, #000 ${dot}px, transparent ${dot}px)`;\n\n return (\n <Fill>\n <div\n style={{\n height: bleed,\n left: \"50%\",\n maskImage: mask,\n maskPosition: \"center\",\n maskRepeat: \"repeat\",\n maskSize: `${cell}px ${cell}px`,\n position: \"absolute\",\n top: \"50%\",\n transform: `translate(-50%, -50%) rotate(${angle}deg)`,\n WebkitMaskImage: mask,\n WebkitMaskPosition: \"center\",\n WebkitMaskRepeat: \"repeat\",\n WebkitMaskSize: `${cell}px ${cell}px`,\n width: bleed,\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n display: \"flex\",\n height: \"100%\",\n justifyContent: \"center\",\n transform: `rotate(${-angle}deg)`,\n width: \"100%\",\n }}\n >\n {/* Back at the true frame size and orientation, so the content never\n knows it travelled through two rotations. */}\n <div style={{height, position: \"relative\", width}}>{children}</div>\n </div>\n </div>\n </Fill>\n );\n};\n",
|
|
2985
|
+
"target": "videos/components/halftone-reveal/halftone-reveal.tsx"
|
|
2986
|
+
},
|
|
2987
|
+
{
|
|
2988
|
+
"path": "components/halftone-reveal/halftone-reveal.preview.tsx",
|
|
2989
|
+
"content": "import {Fill, useBrand} from \"odori\";\nimport {defineComponentPreview} from \"odori/preview\";\nimport {HalftoneReveal} from \"./halftone-reveal\";\n\nconst Card = () => {\n const brand = useBrand();\n return (\n <Fill\n style={{\n alignItems: \"center\",\n background: brand.colors.surface,\n color: brand.colors.foreground,\n display: \"flex\",\n fontFamily: brand.typography.display ?? brand.typography.sans,\n fontSize: 140,\n fontWeight: 700,\n justifyContent: \"center\",\n letterSpacing: \"-0.04em\",\n }}\n >\n In print\n </Fill>\n );\n};\n\nexport default defineComponentPreview({\n title: \"Halftone reveal\",\n category: \"Motion/Transitions\",\n description: \"Content arriving through a growing halftone dot screen, the transition twin of halftone print.\",\n component: HalftoneReveal,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n at: {type: \"number\", defaultValue: 0, min: 0, max: 60},\n durationInFrames: {type: \"number\", defaultValue: 32, min: 8, max: 90},\n pitch: {type: \"number\", defaultValue: 18, min: 6, max: 60, step: 2},\n angle: {type: \"number\", defaultValue: 15, min: 0, max: 45, step: 1},\n },\n examples: [\n {name: \"Default\", props: {children: <Card />}},\n {name: \"Coarse screen\", props: {children: <Card />, pitch: 44, durationInFrames: 44}},\n {name: \"Straight and fine\", props: {children: <Card />, pitch: 10, angle: 0}},\n ],\n});\n",
|
|
2990
|
+
"target": "videos/components/halftone-reveal/halftone-reveal.preview.tsx"
|
|
2991
|
+
}
|
|
2992
|
+
],
|
|
2993
|
+
"meta": {
|
|
2994
|
+
"kind": "component",
|
|
2995
|
+
"family": "Motion",
|
|
2996
|
+
"namespaced": "@odori/halftone-reveal",
|
|
2997
|
+
"contract": {
|
|
2998
|
+
"aspectRatios": [
|
|
2999
|
+
"16:9",
|
|
3000
|
+
"9:16",
|
|
3001
|
+
"1:1"
|
|
3002
|
+
],
|
|
3003
|
+
"recommendedDurationInFrames": 60,
|
|
3004
|
+
"minimumDurationInFrames": 32,
|
|
3005
|
+
"entranceFrames": 32,
|
|
3006
|
+
"exitFrames": 0,
|
|
3007
|
+
"contentLimits": {},
|
|
3008
|
+
"reducedMotion": "dots shown at full bleed, content present from the first frame",
|
|
3009
|
+
"requires": {
|
|
3010
|
+
"fonts": [],
|
|
3011
|
+
"audio": []
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
},
|
|
3016
|
+
{
|
|
3017
|
+
"name": "heatmap",
|
|
3018
|
+
"description": "Grid intensity reveal for activity and distribution.",
|
|
3019
|
+
"registryDependencies": [],
|
|
3020
|
+
"files": [
|
|
3021
|
+
{
|
|
3022
|
+
"path": "components/heatmap/heatmap.tsx",
|
|
3023
|
+
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type HeatmapProps = {\n /** Rows of intensity values, 0 to 1. */\n rows: number[][];\n title?: string;\n rowLabels?: string[];\n};\n\n/**\n * Grid intensity revealed cell by cell, in reading order. Intensity is carried\n * by opacity of one accent color rather than a rainbow, so the scale stays\n * legible at video distance.\n */\nexport const Heatmap = ({rows, title, rowLabels}: HeatmapProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const columns = Math.max(...rows.map((row) => row.length), 1);\n let cellIndex = 0;\n\n return (\n <Fill style={{gap: 34 * scale, justifyContent: \"center\", padding: `${110 * scale}px ${130 * scale}px`}}>\n {title ? (\n <div style={{fontSize: 56 * scale, fontWeight: 560, letterSpacing: \"-0.04em\", opacity: interpolate(frame, [0, 16], [0, 1])}}>\n {title}\n </div>\n ) : null}\n <div style={{display: \"grid\", gap: 12 * scale}}>\n {rows.map((row, rowNumber) => (\n <div key={rowNumber} style={{alignItems: \"center\", display: \"flex\", gap: 18 * scale}}>\n {rowLabels ? (\n <span style={{color: brand.colors.muted, fontSize: 26 * scale, width: 200 * scale}}>\n {rowLabels[rowNumber]}\n </span>\n ) : null}\n <div style={{display: \"grid\", flex: 1, gap: 12 * scale, gridTemplateColumns: `repeat(${columns}, 1fr)`}}>\n {row.map((intensity, columnNumber) => {\n const delay = 10 + cellIndex * 1.5;\n cellIndex += 1;\n const enter = interpolate(frame, [delay, delay + 14], [0, 1], {easing: Easing.standard});\n return (\n <span\n key={columnNumber}\n style={{\n background: `color-mix(in srgb, ${brand.colors.accent} ${Math.round(intensity * 100)}%, ${brand.colors.surface})`,\n borderRadius: 10 * scale,\n height: 62 * scale,\n opacity: enter,\n transform: `scale(${0.86 + enter * 0.14})`,\n }}\n />\n );\n })}\n </div>\n </div>\n ))}\n </div>\n </Fill>\n );\n};\n",
|
|
3024
|
+
"target": "videos/components/heatmap/heatmap.tsx"
|
|
3025
|
+
},
|
|
3026
|
+
{
|
|
3027
|
+
"path": "components/heatmap/heatmap.preview.tsx",
|
|
3028
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Heatmap} from \"./heatmap\";\n\nexport default defineComponentPreview({\n title: \"Heatmap\",\n category: \"Data\",\n description: \"Grid intensity reveal for activity and distribution.\",\n component: Heatmap,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {title: {type: \"text\", defaultValue: \"Renders per day\"}},\n examples: [\n {\n name: \"Activity\",\n props: {\n rowLabels: [\"week 1\", \"week 2\", \"week 3\"],\n rows: [\n [0.1, 0.3, 0.2, 0.6, 0.4, 0.1, 0.05],\n [0.2, 0.5, 0.7, 0.8, 0.6, 0.2, 0.1],\n [0.4, 0.7, 0.9, 1, 0.8, 0.3, 0.15],\n ],\n },\n },\n ],\n});\n",
|
|
3029
|
+
"target": "videos/components/heatmap/heatmap.preview.tsx"
|
|
3030
|
+
}
|
|
3031
|
+
],
|
|
3032
|
+
"meta": {
|
|
3033
|
+
"kind": "component",
|
|
3034
|
+
"family": "Data",
|
|
3035
|
+
"namespaced": "@odori/heatmap",
|
|
3036
|
+
"contract": {
|
|
3037
|
+
"aspectRatios": [
|
|
3038
|
+
"16:9",
|
|
3039
|
+
"9:16",
|
|
3040
|
+
"1:1"
|
|
3041
|
+
],
|
|
3042
|
+
"recommendedDurationInFrames": 120,
|
|
3043
|
+
"minimumDurationInFrames": 60,
|
|
3044
|
+
"entranceFrames": 50,
|
|
3045
|
+
"exitFrames": 0,
|
|
3046
|
+
"contentLimits": {
|
|
3047
|
+
"rows": 6,
|
|
3048
|
+
"columns": 10
|
|
3049
|
+
},
|
|
3050
|
+
"reducedMotion": "cells appear together",
|
|
3051
|
+
"requires": {
|
|
3052
|
+
"fonts": [
|
|
3053
|
+
"sans"
|
|
3054
|
+
],
|
|
3055
|
+
"audio": []
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
},
|
|
3060
|
+
{
|
|
3061
|
+
"name": "html-canvas",
|
|
3062
|
+
"description": "HTML authored into a canvas-safe rendered surface.",
|
|
3063
|
+
"registryDependencies": [],
|
|
3064
|
+
"files": [
|
|
3065
|
+
{
|
|
3066
|
+
"path": "components/html-canvas/html-canvas.tsx",
|
|
3067
|
+
"content": "import {useEffect, useRef, useState} from \"react\";\nimport {Easing, Fill, drawHtml, interpolate, useBrand, useFrame, useReadiness, useVideo} from \"odori\";\n\nexport type HtmlCanvasProps = {\n /** Markup to rasterize. Every element must carry inline styles. */\n html: string;\n /** Style applied to the wrapper inside the SVG. */\n style?: string;\n /** Canvas size in composition pixels. Defaults to the whole frame. */\n width?: number;\n height?: number;\n /** Frames the result takes to fade up. */\n entranceFrames?: number;\n};\n\n/**\n * HTML drawn into the frame as pixels rather than as live DOM.\n *\n * The reason to want this is compositing: once markup is a canvas it can be\n * warped, masked, blurred, sampled, or fed to another draw call, none of which\n * a live DOM subtree can do. The reason it needs a component rather than a\n * snippet is that getting it right means holding the frame until the raster\n * lands and waiting for fonts first, and both are easy to forget once and hard\n * to notice afterwards.\n *\n * The markup is self-contained by necessity. An SVG `foreignObject` cannot see\n * the page's stylesheets, so styles are inline and images are data URLs; there\n * is no way to make that ergonomic, so it is at least stated plainly and the\n * failure names it.\n */\nexport const HtmlCanvas = ({html, style, width, height, entranceFrames = 12}: HtmlCanvasProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const video = useVideo();\n const readiness = useReadiness();\n const canvas = useRef<HTMLCanvasElement | null>(null);\n const [failed, setFailed] = useState<string | null>(null);\n\n const canvasWidth = width ?? video.width;\n const canvasHeight = height ?? video.height;\n\n // The raster is asynchronous, so it cannot use the synchronous useCanvas\n // path: the frame is held by hand until the image has been drawn.\n useEffect(() => {\n const element = canvas.current;\n if (!element) return;\n const context = element.getContext(\"2d\");\n if (!context) return;\n\n let cancelled = false;\n const release = readiness.hold();\n context.clearRect(0, 0, element.width, element.height);\n drawHtml(context, html, {\n width: canvasWidth,\n height: canvasHeight,\n style: style ?? `color:${brand.colors.foreground};font-family:${brand.typography.sans};`,\n })\n .catch((error: Error) => {\n if (!cancelled) setFailed(error.message);\n })\n .finally(() => {\n if (!cancelled) release();\n });\n\n return () => {\n cancelled = true;\n release();\n };\n }, [brand.colors.foreground, brand.typography.sans, canvasHeight, canvasWidth, html, readiness, style]);\n\n const appear = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\"}}>\n <canvas\n ref={canvas}\n width={canvasWidth}\n height={canvasHeight}\n style={{height: canvasHeight, opacity: appear, width: canvasWidth}}\n />\n {failed ? (\n <span\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 24,\n maxWidth: \"70%\",\n position: \"absolute\",\n textAlign: \"center\",\n }}\n >\n {failed}\n </span>\n ) : null}\n </Fill>\n );\n};\n",
|
|
3068
|
+
"target": "videos/components/html-canvas/html-canvas.tsx"
|
|
3069
|
+
},
|
|
3070
|
+
{
|
|
3071
|
+
"path": "components/html-canvas/html-canvas.preview.tsx",
|
|
3072
|
+
"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",
|
|
3073
|
+
"target": "videos/components/html-canvas/html-canvas.preview.tsx"
|
|
3074
|
+
}
|
|
3075
|
+
],
|
|
3076
|
+
"meta": {
|
|
3077
|
+
"kind": "component",
|
|
3078
|
+
"family": "Media",
|
|
3079
|
+
"namespaced": "@odori/html-canvas",
|
|
3080
|
+
"contract": {
|
|
3081
|
+
"aspectRatios": [
|
|
3082
|
+
"16:9",
|
|
3083
|
+
"9:16",
|
|
3084
|
+
"1:1"
|
|
3085
|
+
],
|
|
3086
|
+
"recommendedDurationInFrames": 90,
|
|
3087
|
+
"minimumDurationInFrames": 24,
|
|
3088
|
+
"entranceFrames": 12,
|
|
3089
|
+
"exitFrames": 0,
|
|
3090
|
+
"contentLimits": {
|
|
3091
|
+
"html": 4000
|
|
3092
|
+
},
|
|
3093
|
+
"reducedMotion": "the raster appears without fading in",
|
|
3094
|
+
"requires": {
|
|
3095
|
+
"fonts": [
|
|
3096
|
+
"sans"
|
|
3097
|
+
],
|
|
3098
|
+
"audio": []
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
},
|
|
3103
|
+
{
|
|
3104
|
+
"name": "icon-set",
|
|
3105
|
+
"description": "A stroked icon set that draws itself on, as editable SVG source.",
|
|
3106
|
+
"registryDependencies": [],
|
|
3107
|
+
"files": [
|
|
3108
|
+
{
|
|
3109
|
+
"path": "components/icon-set/icon-set.tsx",
|
|
3110
|
+
"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",
|
|
3111
|
+
"target": "videos/components/icon-set/icon-set.tsx"
|
|
3112
|
+
},
|
|
3113
|
+
{
|
|
3114
|
+
"path": "components/icon-set/icon-set.preview.tsx",
|
|
3115
|
+
"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",
|
|
3116
|
+
"target": "videos/components/icon-set/icon-set.preview.tsx"
|
|
3117
|
+
}
|
|
3118
|
+
],
|
|
3119
|
+
"meta": {
|
|
3120
|
+
"kind": "component",
|
|
3121
|
+
"family": "Brand",
|
|
3122
|
+
"namespaced": "@odori/icon-set",
|
|
3123
|
+
"contract": {
|
|
3124
|
+
"aspectRatios": [
|
|
3125
|
+
"16:9",
|
|
3126
|
+
"9:16",
|
|
3127
|
+
"1:1"
|
|
3128
|
+
],
|
|
3129
|
+
"recommendedDurationInFrames": 180,
|
|
3130
|
+
"minimumDurationInFrames": 60,
|
|
3131
|
+
"entranceFrames": 18,
|
|
3132
|
+
"exitFrames": 0,
|
|
3133
|
+
"contentLimits": {
|
|
3134
|
+
"names": 56
|
|
3135
|
+
},
|
|
3136
|
+
"reducedMotion": "every icon renders complete, with no draw on",
|
|
3137
|
+
"requires": {
|
|
3138
|
+
"fonts": [
|
|
3139
|
+
"sans"
|
|
3140
|
+
],
|
|
3141
|
+
"audio": []
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
},
|
|
3146
|
+
{
|
|
3147
|
+
"name": "identity-reveal",
|
|
3148
|
+
"description": "Product mark and name entrance with visual restraint.",
|
|
3149
|
+
"registryDependencies": [],
|
|
3150
|
+
"files": [
|
|
3151
|
+
{
|
|
3152
|
+
"path": "components/identity-reveal/identity-reveal.tsx",
|
|
3153
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type IdentityRevealProps = {\n /** Defaults to the brand name, so a project never hardcodes its own mark. */\n wordmark?: string;\n /**\n * Your own mark, as JSX. Inline SVG scales with the design, takes brand\n * colors, and can animate per frame, which a file can never do.\n */\n mark?: ReactNode;\n /** A key in `brand.logos`, or a URL. Used when no `mark` is given. */\n logo?: string;\n detail?: string;\n};\n\n/**\n * The product mark and name entering together: the mark settles first, the\n * wordmark follows out of a clipped box, and a rule draws under both. Restraint\n * is the point, so nothing spins or bounces.\n */\nexport const IdentityReveal = ({wordmark, mark: markNode, logo, detail}: IdentityRevealProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const name = wordmark ?? brand.name;\n const source = logo ? brand.logos[logo] ?? logo : undefined;\n\n const mark = interpolate(frame, [0, 24], [0, 1], {easing: Easing.standard});\n const word = interpolate(frame, [12, 38], [0, 1], {easing: Easing.standard});\n const rule = interpolate(frame, [28, 54], [0, 1], {easing: Easing.standard});\n const note = interpolate(frame, [38, 60], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", gap: 26 * scale, justifyContent: \"center\"}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: 26 * scale}}>\n {markNode ? (\n <div\n style={{\n alignItems: \"center\",\n display: \"flex\",\n height: 88 * scale,\n opacity: mark,\n transform: `scale(${0.92 + mark * 0.08})`,\n }}\n >\n {markNode}\n </div>\n ) : source ? (\n <img alt=\"\" src={source} style={{height: 88 * scale, opacity: mark, transform: `scale(${0.92 + mark * 0.08})`}} />\n ) : (\n <span\n style={{\n background: brand.colors.accent,\n borderRadius: 18 * scale,\n display: \"block\",\n height: 72 * scale,\n opacity: mark,\n transform: `scale(${0.9 + mark * 0.1})`,\n width: 72 * scale,\n }}\n />\n )}\n <div style={{overflow: \"hidden\", paddingBottom: 8 * scale}}>\n <span\n style={{\n display: \"inline-block\",\n fontSize: 96 * scale,\n fontWeight: 600,\n letterSpacing: \"-0.05em\",\n opacity: word,\n transform: `translateY(${(1 - word) * 66 * scale}px)`,\n }}\n >\n {name}\n </span>\n </div>\n </div>\n\n <div style={{background: brand.colors.border, height: Math.max(1, 1 * scale), transform: `scaleX(${rule})`, width: 420 * scale}} />\n\n {detail ? <div style={{color: brand.colors.muted, fontSize: 32 * scale, opacity: note}}>{detail}</div> : null}\n </Fill>\n );\n};\n",
|
|
3154
|
+
"target": "videos/components/identity-reveal/identity-reveal.tsx"
|
|
2539
3155
|
},
|
|
2540
3156
|
{
|
|
2541
3157
|
"path": "components/identity-reveal/identity-reveal.preview.tsx",
|
|
@@ -2654,6 +3270,48 @@
|
|
|
2654
3270
|
}
|
|
2655
3271
|
}
|
|
2656
3272
|
},
|
|
3273
|
+
{
|
|
3274
|
+
"name": "ink-bloom",
|
|
3275
|
+
"description": "Four travelling sine fields summed into slow interference, mapped onto two brand colours.",
|
|
3276
|
+
"registryDependencies": [],
|
|
3277
|
+
"files": [
|
|
3278
|
+
{
|
|
3279
|
+
"path": "components/ink-bloom/ink-bloom.tsx",
|
|
3280
|
+
"content": "import {Fill, useBrand, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * Classic plasma: four sine fields travelling in different directions, summed\n * and mapped onto two brand colours.\n *\n * The demoscene version ran hot and fast; this one is tuned as a ground. The\n * interference blobs are large, the phases crawl, and the palette defaults to\n * background-into-accent so the pattern reads as weather behind the content\n * rather than the content itself. Contrast is the control worth having: at 0\n * the sum stays a soft wash, at 1 the midpoint hardens into cell walls.\n *\n * Every phase is a function of `seconds`, so a longer cut keeps travelling\n * instead of looping, and nothing here samples anything random at all.\n */\nexport type InkBloomProps = {\n /** Two colours the plasma sits between. Defaults to the brand. */\n colors?: [string, string];\n /** Size of the interference blobs — higher is busier. */\n scale?: number;\n /** How fast the fields travel through each other. */\n speed?: number;\n /** 0 is a soft wash, 1 is hard-walled cells. */\n contrast?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst field = defineShaderEffect({\n name: \"ink-bloom\",\n uniforms: {\n scale: numberUniform(1.5),\n speed: numberUniform(0.2),\n contrast: numberUniform(0.4),\n low: vec3Uniform([0.04, 0.05, 0.09]),\n high: vec3Uniform([0.45, 0.55, 0.85]),\n },\n fragmentShader: `\nuniform float scale;\nuniform float speed;\nuniform float contrast;\nuniform vec3 low;\nuniform vec3 high;\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n uv.x *= resolution.x / resolution.y;\n vec2 p = uv * scale * 3.0;\n float t = seconds * speed;\n\n /* Four travelling waves: two axis-aligned, one diagonal, one radial around\n a centre that itself wanders. Incommensurate frequencies keep the sum\n from ever visibly repeating. */\n float v = sin(p.x * 1.1 + t);\n v += sin(p.y * 0.83 - t * 0.7);\n v += sin((p.x + p.y) * 0.71 + t * 1.3);\n vec2 centre = vec2(sin(t * 0.43), cos(t * 0.31)) * 1.7;\n v += sin(length(p - centre) * 1.27 - t * 0.9);\n\n /* The sum of four unit sines lands in [-4, 4]; fold it to [0, 1] and let\n contrast push the midpoint toward a hard edge. */\n float n = v * 0.125 + 0.5;\n n = mix(n, smoothstep(0.38, 0.62, n), contrast);\n odoriColour = vec4(mix(low, high, clamp(n, 0.0, 1.0)), 1.0);\n}`,\n});\n\nexport const InkBloom = ({colors, scale = 1.5, speed = 0.2, contrast = 0.4}: InkBloomProps) => {\n const brand = useBrand();\n const {width, height} = useVideo();\n const [low, high] = colors ?? [brand.colors.background, brand.colors.accent];\n\n return (\n <EffectSurface effects={[field({scale, speed, contrast, low: hexToRgb(low), high: hexToRgb(high)})]}>\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n",
|
|
3281
|
+
"target": "videos/components/ink-bloom/ink-bloom.tsx"
|
|
3282
|
+
},
|
|
3283
|
+
{
|
|
3284
|
+
"path": "components/ink-bloom/ink-bloom.preview.tsx",
|
|
3285
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {InkBloom} from \"./ink-bloom\";\n\nexport default defineComponentPreview({\n title: \"Ink bloom\",\n category: \"Backgrounds\",\n description: \"Four travelling sine fields summed into slow interference, mapped onto two brand colours.\",\n component: InkBloom,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n scale: {type: \"number\", defaultValue: 1.5, min: 0.5, max: 5, step: 0.25},\n speed: {type: \"number\", defaultValue: 0.2, min: 0, max: 1, step: 0.05},\n contrast: {type: \"number\", defaultValue: 0.4, min: 0, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Cells\", props: {contrast: 1, scale: 2.5}},\n {name: \"Lava\", props: {colors: [\"#1a0505\", \"#ff6b35\"], scale: 1, speed: 0.12}},\n ],\n});\n",
|
|
3286
|
+
"target": "videos/components/ink-bloom/ink-bloom.preview.tsx"
|
|
3287
|
+
}
|
|
3288
|
+
],
|
|
3289
|
+
"meta": {
|
|
3290
|
+
"kind": "component",
|
|
3291
|
+
"family": "Backgrounds",
|
|
3292
|
+
"namespaced": "@odori/ink-bloom",
|
|
3293
|
+
"contract": {
|
|
3294
|
+
"aspectRatios": [
|
|
3295
|
+
"16:9",
|
|
3296
|
+
"9:16",
|
|
3297
|
+
"1:1"
|
|
3298
|
+
],
|
|
3299
|
+
"recommendedDurationInFrames": 180,
|
|
3300
|
+
"minimumDurationInFrames": 30,
|
|
3301
|
+
"entranceFrames": 0,
|
|
3302
|
+
"exitFrames": 0,
|
|
3303
|
+
"contentLimits": {},
|
|
3304
|
+
"reducedMotion": "set speed to 0 and the interference pattern holds still",
|
|
3305
|
+
"requires": {
|
|
3306
|
+
"fonts": [],
|
|
3307
|
+
"audio": [],
|
|
3308
|
+
"graphics": [
|
|
3309
|
+
"webgl2"
|
|
3310
|
+
]
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
},
|
|
2657
3315
|
{
|
|
2658
3316
|
"name": "kinetic-center",
|
|
2659
3317
|
"description": "A centered statement assembled from timed word groups.",
|
|
@@ -2999,37 +3657,120 @@
|
|
|
2999
3657
|
}
|
|
3000
3658
|
},
|
|
3001
3659
|
{
|
|
3002
|
-
"name": "
|
|
3003
|
-
"description": "
|
|
3660
|
+
"name": "masonry-assemble",
|
|
3661
|
+
"description": "A masonry grid that lays itself out tile by tile on a seeded stagger, then holds.",
|
|
3004
3662
|
"registryDependencies": [],
|
|
3005
3663
|
"files": [
|
|
3006
3664
|
{
|
|
3007
|
-
"path": "components/
|
|
3008
|
-
"content": "import {
|
|
3009
|
-
"target": "videos/components/
|
|
3665
|
+
"path": "components/masonry-assemble/masonry-assemble.tsx",
|
|
3666
|
+
"content": "import {Children, type ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, random, randomBetween, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type MasonryAssembleProps = {\n /** The tiles, in the order they should appear. */\n children?: ReactNode;\n columns?: number;\n /** Frames between one tile starting its rise and the next. */\n stagger?: number;\n /** Gutter between tiles, in design pixels. */\n gap?: number;\n};\n\n/**\n * A masonry grid that builds itself, then holds.\n *\n * A grid that is simply there reads as a screenshot of a website. One that\n * assembles — each tile rising and fading in, in roughly the order given —\n * reads as a collection being laid out for you, and then it has the good\n * sense to stop. The stagger carries seeded jitter because tiles arriving on\n * exact multiples sound like a metronome; the jitter is from the seeded\n * generator, so every render lays the same bricks.\n *\n * Columns are filled round-robin, not by measured height. Children are opaque\n * boxes here, and even counts read as balanced when heights vary within\n * reason; a caller with one towering tile should order children with that in\n * mind.\n */\nexport const MasonryAssemble = ({children, columns = 3, stagger = 5, gap = 20}: MasonryAssembleProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const supplied = Children.toArray(children);\n const tiles =\n supplied.length > 0\n ? supplied\n : Array.from({length: 9}, (_, index) => (\n <div\n key={index}\n style={{\n background: `linear-gradient(${140 + index * 25}deg, color-mix(in srgb, ${brand.colors.accent} ${18 + (index % 3) * 14}%, ${brand.colors.surface}), ${brand.colors.surface})`,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 20 * scale,\n height: randomBetween([\"masonry-tile\", index], 180, 380) * scale,\n }}\n />\n ));\n\n const cols = Math.max(1, columns);\n const lanes: Array<Array<{tile: ReactNode; order: number}>> = Array.from({length: cols}, () => []);\n tiles.forEach((tile, order) => lanes[order % cols]?.push({tile, order}));\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\"}}>\n <div style={{alignItems: \"flex-start\", display: \"flex\", gap: gap * scale, width: \"78%\"}}>\n {lanes.map((lane, laneIndex) => (\n <div key={laneIndex} style={{display: \"flex\", flex: 1, flexDirection: \"column\", gap: gap * scale}}>\n {lane.map(({tile, order}) => {\n const start = order * stagger + random([\"assemble\", order]) * stagger * 1.5;\n const rise = interpolate(frame, [start, start + 16], [0, 1], {easing: Easing.standard});\n return (\n <div key={order} style={{opacity: rise, transform: `translateY(${(1 - rise) * 44 * scale}px)`}}>\n {tile}\n </div>\n );\n })}\n </div>\n ))}\n </div>\n </Fill>\n );\n};\n",
|
|
3667
|
+
"target": "videos/components/masonry-assemble/masonry-assemble.tsx"
|
|
3010
3668
|
},
|
|
3011
3669
|
{
|
|
3012
|
-
"path": "components/
|
|
3013
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {
|
|
3014
|
-
"target": "videos/components/
|
|
3670
|
+
"path": "components/masonry-assemble/masonry-assemble.preview.tsx",
|
|
3671
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {MasonryAssemble} from \"./masonry-assemble\";\n\nconst hues = [\"#233a63\", \"#2c2354\", \"#14324a\", \"#31283e\", \"#1f3d33\", \"#3d2a1f\"];\nconst heights = [240, 340, 200, 300, 260, 380, 220, 320];\n\nconst Tile = ({index}: {index: number}) => (\n <div\n style={{\n background: `linear-gradient(${130 + index * 30}deg, ${hues[index % hues.length]}, #0b1120)`,\n borderRadius: 20,\n height: heights[index % heights.length],\n }}\n />\n);\n\nexport default defineComponentPreview({\n title: \"Masonry assemble\",\n category: \"Media/Footage\",\n description: \"A masonry grid that lays itself out tile by tile on a seeded stagger, then holds.\",\n component: MasonryAssemble,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n columns: {type: \"number\", defaultValue: 3, min: 2, max: 4},\n stagger: {type: \"number\", defaultValue: 5, min: 1, max: 15},\n gap: {type: \"number\", defaultValue: 20, min: 8, max: 48},\n },\n examples: [\n {name: \"Default\", props: {}},\n {\n name: \"Gallery\",\n props: {children: Array.from({length: 8}, (_, index) => <Tile key={index} index={index} />)},\n },\n {\n name: \"Slow build, two lanes\",\n props: {\n columns: 2,\n stagger: 10,\n children: Array.from({length: 6}, (_, index) => <Tile key={index} index={index} />),\n },\n },\n ],\n});\n",
|
|
3672
|
+
"target": "videos/components/masonry-assemble/masonry-assemble.preview.tsx"
|
|
3015
3673
|
}
|
|
3016
3674
|
],
|
|
3017
3675
|
"meta": {
|
|
3018
3676
|
"kind": "component",
|
|
3019
|
-
"family": "
|
|
3020
|
-
"namespaced": "@odori/
|
|
3677
|
+
"family": "Media",
|
|
3678
|
+
"namespaced": "@odori/masonry-assemble",
|
|
3021
3679
|
"contract": {
|
|
3022
3680
|
"aspectRatios": [
|
|
3023
3681
|
"16:9",
|
|
3024
3682
|
"9:16",
|
|
3025
3683
|
"1:1"
|
|
3026
3684
|
],
|
|
3027
|
-
"recommendedDurationInFrames":
|
|
3028
|
-
"minimumDurationInFrames":
|
|
3029
|
-
"entranceFrames":
|
|
3685
|
+
"recommendedDurationInFrames": 150,
|
|
3686
|
+
"minimumDurationInFrames": 90,
|
|
3687
|
+
"entranceFrames": 70,
|
|
3030
3688
|
"exitFrames": 0,
|
|
3031
|
-
"contentLimits": {
|
|
3032
|
-
|
|
3689
|
+
"contentLimits": {
|
|
3690
|
+
"items": 12
|
|
3691
|
+
},
|
|
3692
|
+
"reducedMotion": "tiles fade in together without rising",
|
|
3693
|
+
"requires": {
|
|
3694
|
+
"fonts": [],
|
|
3695
|
+
"audio": []
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
},
|
|
3700
|
+
{
|
|
3701
|
+
"name": "mesh-gradient",
|
|
3702
|
+
"description": "Four colour centres blended by inverse distance, so they pass through each other cleanly.",
|
|
3703
|
+
"registryDependencies": [],
|
|
3704
|
+
"files": [
|
|
3705
|
+
{
|
|
3706
|
+
"path": "components/mesh-gradient/mesh-gradient.tsx",
|
|
3707
|
+
"content": "import {Fill, useBrand, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * Four colour centres drifting past each other, blended on the GPU.\n *\n * The sibling of `gradient-field`, which does a similar job with blurred DOM\n * and repeats exactly across a cycle. This one is a shader: the centres blend\n * by inverse distance rather than by stacking blurs, so they pass through each\n * other without an edge and the falloff is a control rather than a blur\n * radius. Reach for the DOM one when a background has to loop seamlessly, and\n * this one when it has to look like a mesh gradient.\n *\n * Colours default to the brand, so it already looks like the project.\n */\nexport type MeshGradientProps = {\n /** Two to four colours. Beyond four the extra ones are ignored. */\n colors?: string[];\n /** How far the centres travel. 0 holds them still. */\n drift?: number;\n /** How sharply one colour gives way to the next. */\n falloff?: number;\n /** Grain over the blend, which keeps a wide gradient from banding. */\n grain?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst field = defineShaderEffect({\n name: \"mesh-gradient\",\n uniforms: {\n drift: numberUniform(1),\n falloff: numberUniform(1.6),\n grain: numberUniform(0.03),\n colourA: vec3Uniform([0.11, 0.13, 0.22]),\n colourB: vec3Uniform([0.35, 0.22, 0.55]),\n colourC: vec3Uniform([0.12, 0.35, 0.45]),\n colourD: vec3Uniform([0.05, 0.06, 0.1]),\n },\n fragmentShader: `\nuniform float drift;\nuniform float falloff;\nuniform float grain;\nuniform vec3 colourA;\nuniform vec3 colourB;\nuniform vec3 colourC;\nuniform vec3 colourD;\n\n/* Inverse-distance blending rather than a gradient stop, so the centres can\n pass through each other without a visible edge. */\nvec3 blend(vec2 uv, vec2 p, vec3 c, inout float total) {\n float w = 1.0 / (pow(distance(uv, p), falloff) + 0.0001);\n total += w;\n return c * w;\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n /* Corrected for shape, or the centres travel in ovals on a wide frame. */\n vec2 aspect = vec2(resolution.x / resolution.y, 1.0);\n uv *= aspect;\n float t = progress * 6.2831853 * drift;\n\n float total = 0.0;\n vec3 sum = vec3(0.0);\n sum += blend(uv, vec2(0.28, 0.30) * aspect + vec2(cos(t), sin(t * 0.9)) * 0.16 * drift, colourA, total);\n sum += blend(uv, vec2(0.76, 0.26) * aspect + vec2(cos(t * 1.3 + 2.0), sin(t * 0.7)) * 0.14 * drift, colourB, total);\n sum += blend(uv, vec2(0.30, 0.74) * aspect + vec2(sin(t * 0.8), cos(t * 1.1)) * 0.15 * drift, colourC, total);\n sum += blend(uv, vec2(0.74, 0.78) * aspect + vec2(sin(t * 1.2 + 1.0), cos(t * 0.6)) * 0.13 * drift, colourD, total);\n\n vec3 colour = sum / total;\n /* A wide gradient bands across 8-bit output without this. */\n colour += (hash(gl_FragCoord.xy + frame) - 0.5) * grain;\n odoriColour = vec4(colour, 1.0);\n}`,\n});\n\nexport const MeshGradient = ({colors, drift = 1, falloff = 1.6, grain = 0.03}: MeshGradientProps) => {\n const brand = useBrand();\n const {width, height} = useVideo();\n const palette = colors?.length\n ? colors\n : [brand.colors.accent, brand.colors.surface, brand.colors.muted, brand.colors.background];\n const [a, b, c, d] = [0, 1, 2, 3].map((index) => hexToRgb(palette[index % palette.length]));\n\n return (\n <EffectSurface effects={[field({drift, falloff, grain, colourA: a, colourB: b, colourC: c, colourD: d})]}>\n {/* The surface needs something to photograph even where the shader\n ignores it, and a flat fill is the cheapest thing to capture. */}\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n",
|
|
3708
|
+
"target": "videos/components/mesh-gradient/mesh-gradient.tsx"
|
|
3709
|
+
},
|
|
3710
|
+
{
|
|
3711
|
+
"path": "components/mesh-gradient/mesh-gradient.preview.tsx",
|
|
3712
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {MeshGradient} from \"./mesh-gradient\";\n\nexport default defineComponentPreview({\n title: \"Mesh gradient\",\n category: \"Backgrounds\",\n description: \"Four colour centres blended by inverse distance, so they pass through each other cleanly.\",\n component: MeshGradient,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n drift: {type: \"number\", defaultValue: 1, min: 0, max: 3, step: 0.1},\n falloff: {type: \"number\", defaultValue: 1.6, min: 0.6, max: 4, step: 0.1},\n grain: {type: \"number\", defaultValue: 0.03, min: 0, max: 0.12, step: 0.01},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Still\", props: {drift: 0}},\n {name: \"Sunset\", props: {colors: [\"#2b1055\", \"#7597de\", \"#ff7e5f\", \"#120318\"]}},\n ],\n});\n",
|
|
3713
|
+
"target": "videos/components/mesh-gradient/mesh-gradient.preview.tsx"
|
|
3714
|
+
}
|
|
3715
|
+
],
|
|
3716
|
+
"meta": {
|
|
3717
|
+
"kind": "component",
|
|
3718
|
+
"family": "Backgrounds",
|
|
3719
|
+
"namespaced": "@odori/mesh-gradient",
|
|
3720
|
+
"contract": {
|
|
3721
|
+
"aspectRatios": [
|
|
3722
|
+
"16:9",
|
|
3723
|
+
"9:16",
|
|
3724
|
+
"1:1"
|
|
3725
|
+
],
|
|
3726
|
+
"recommendedDurationInFrames": 180,
|
|
3727
|
+
"minimumDurationInFrames": 30,
|
|
3728
|
+
"entranceFrames": 0,
|
|
3729
|
+
"exitFrames": 0,
|
|
3730
|
+
"contentLimits": {},
|
|
3731
|
+
"reducedMotion": "set drift to 0 and the field holds still",
|
|
3732
|
+
"requires": {
|
|
3733
|
+
"fonts": [],
|
|
3734
|
+
"audio": [],
|
|
3735
|
+
"graphics": [
|
|
3736
|
+
"webgl2"
|
|
3737
|
+
]
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
},
|
|
3742
|
+
{
|
|
3743
|
+
"name": "meta-blobs",
|
|
3744
|
+
"description": "Seeded blobs orbiting through one gooey silhouette, merging and parting as they pass.",
|
|
3745
|
+
"registryDependencies": [],
|
|
3746
|
+
"files": [
|
|
3747
|
+
{
|
|
3748
|
+
"path": "components/meta-blobs/meta-blobs.tsx",
|
|
3749
|
+
"content": "import {Fill, randomBetween, useBrand, useFrame, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * Blobs that merge and part as they drift, one crisp gooey silhouette.\n *\n * The goo is a field, not geometry: each blob contributes r²/d² and one tight\n * threshold decides inside from outside, which is what makes two blobs neck\n * into each other instead of overlapping like circles. The fill is flat on a\n * flat ground on purpose — a gradient here reads as lava lamp, a silhouette\n * reads as a mark.\n *\n * Every orbit is seeded by name, so the choreography is identical in the\n * preview, in every export, and in every render worker.\n */\nexport type MetaBlobsProps = {\n /** How many blobs share the field. Clamped to 4-7. */\n count?: number;\n /** Fill of the silhouette. Defaults to the brand accent. */\n color?: string;\n /** The ground behind it. Defaults to the brand background. */\n background?: string;\n /** Radius of a typical blob, as a fraction of frame height. */\n size?: number;\n /** How fast the orbits turn. */\n speed?: number;\n /** Field level that counts as inside. Lower merges sooner. */\n threshold?: number;\n};\n\nconst BLOBS = 7;\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst goo = defineShaderEffect({\n name: \"meta-blobs\",\n uniforms: {\n blob0: vec3Uniform([0.5, 0.5, 0]),\n blob1: vec3Uniform([0.5, 0.5, 0]),\n blob2: vec3Uniform([0.5, 0.5, 0]),\n blob3: vec3Uniform([0.5, 0.5, 0]),\n blob4: vec3Uniform([0.5, 0.5, 0]),\n blob5: vec3Uniform([0.5, 0.5, 0]),\n blob6: vec3Uniform([0.5, 0.5, 0]),\n threshold: numberUniform(1),\n fill: vec3Uniform([1, 1, 1]),\n ground: vec3Uniform([0, 0, 0]),\n },\n fragmentShader: `\nuniform vec3 blob0;\nuniform vec3 blob1;\nuniform vec3 blob2;\nuniform vec3 blob3;\nuniform vec3 blob4;\nuniform vec3 blob5;\nuniform vec3 blob6;\nuniform float threshold;\nuniform vec3 fill;\nuniform vec3 ground;\n\n/* The classic metaball charge. A blob with radius zero contributes nothing,\n which is how the inactive slots switch off without a branch. */\nfloat charge(vec3 blob, vec2 p) {\n vec2 d = p - blob.xy;\n return (blob.z * blob.z) / max(dot(d, d), 1e-6);\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n uv.x *= resolution.x / resolution.y;\n\n float field = charge(blob0, uv) + charge(blob1, uv) + charge(blob2, uv)\n + charge(blob3, uv) + charge(blob4, uv) + charge(blob5, uv) + charge(blob6, uv);\n\n /* One tight step. The field is steep where blobs meet the threshold, so a\n narrow smoothstep gives a clean edge at any resolution; a wide one melts\n the silhouette into a glow, which is a different component. */\n float inside = smoothstep(threshold - 0.06, threshold + 0.06, field);\n odoriColour = vec4(mix(ground, fill, inside), 1.0);\n}`,\n});\n\nexport const MetaBlobs = ({\n count = 5,\n color,\n background,\n size = 0.16,\n speed = 0.6,\n threshold = 1,\n}: MetaBlobsProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const {fps, width, height} = useVideo();\n\n const ground = background ?? brand.colors.background;\n const ink = color ?? brand.colors.accent;\n const aspect = width / height;\n const t = (frame / fps) * speed;\n const active = Math.max(4, Math.min(BLOBS, Math.round(count)));\n\n /* Each slot owns a seeded orbit: centre, two radii, a rate, and a phase.\n Positions are computed here per frame — a pure function of the frame\n number — and handed to the shader, which never needs to know about time. */\n const values: Record<string, number | readonly number[]> = {\n threshold,\n fill: hexToRgb(ink),\n ground: hexToRgb(ground),\n };\n for (let index = 0; index < BLOBS; index += 1) {\n const key = `meta-blobs:${index}`;\n const rate = randomBetween(`${key}:rate`, 0.5, 1.1);\n const phase = randomBetween(`${key}:phase`, 0, Math.PI * 2);\n const x =\n randomBetween(`${key}:cx`, 0.32, 0.68) * aspect +\n Math.cos(t * rate + phase) * randomBetween(`${key}:ox`, 0.08, 0.24);\n const y =\n randomBetween(`${key}:cy`, 0.34, 0.66) +\n Math.sin(t * rate * 0.85 + phase) * randomBetween(`${key}:oy`, 0.08, 0.2);\n const radius = index < active ? size * randomBetween(`${key}:r`, 0.7, 1.25) : 0;\n values[`blob${index}`] = [x, y, radius];\n }\n\n return (\n <EffectSurface effects={[goo(values)]}>\n <Fill style={{background: ground, height, width}} />\n </EffectSurface>\n );\n};\n",
|
|
3750
|
+
"target": "videos/components/meta-blobs/meta-blobs.tsx"
|
|
3751
|
+
},
|
|
3752
|
+
{
|
|
3753
|
+
"path": "components/meta-blobs/meta-blobs.preview.tsx",
|
|
3754
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {MetaBlobs} from \"./meta-blobs\";\n\nexport default defineComponentPreview({\n title: \"Meta blobs\",\n category: \"Motion\",\n description: \"Seeded blobs orbiting through one gooey silhouette, merging and parting as they pass.\",\n component: MetaBlobs,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n count: {type: \"number\", defaultValue: 5, min: 4, max: 7, step: 1},\n size: {type: \"number\", defaultValue: 0.16, min: 0.06, max: 0.3, step: 0.01},\n speed: {type: \"number\", defaultValue: 0.6, min: 0, max: 2, step: 0.05},\n threshold: {type: \"number\", defaultValue: 1, min: 0.5, max: 2, step: 0.05},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Crowded\", props: {count: 7, size: 0.12, speed: 0.9}},\n {name: \"Ink on paper\", props: {color: \"#141414\", background: \"#f4f1ea\", threshold: 1.2}},\n ],\n});\n",
|
|
3755
|
+
"target": "videos/components/meta-blobs/meta-blobs.preview.tsx"
|
|
3756
|
+
}
|
|
3757
|
+
],
|
|
3758
|
+
"meta": {
|
|
3759
|
+
"kind": "component",
|
|
3760
|
+
"family": "Motion",
|
|
3761
|
+
"namespaced": "@odori/meta-blobs",
|
|
3762
|
+
"contract": {
|
|
3763
|
+
"aspectRatios": [
|
|
3764
|
+
"16:9",
|
|
3765
|
+
"9:16",
|
|
3766
|
+
"1:1"
|
|
3767
|
+
],
|
|
3768
|
+
"recommendedDurationInFrames": 180,
|
|
3769
|
+
"minimumDurationInFrames": 30,
|
|
3770
|
+
"entranceFrames": 0,
|
|
3771
|
+
"exitFrames": 0,
|
|
3772
|
+
"contentLimits": {},
|
|
3773
|
+
"reducedMotion": "set speed to 0 and the silhouette holds still",
|
|
3033
3774
|
"requires": {
|
|
3034
3775
|
"fonts": [],
|
|
3035
3776
|
"audio": [],
|
|
@@ -3091,12 +3832,12 @@
|
|
|
3091
3832
|
"files": [
|
|
3092
3833
|
{
|
|
3093
3834
|
"path": "components/modal-flow/modal-flow.tsx",
|
|
3094
|
-
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type ModalFlowProps = {\n /** What sits behind the dialog. */\n children?: ReactNode;\n title?: string;\n body?: string;\n /** The primary button's label. */\n confirm?: string;\n cancel?: string;\n /** What the surface says once the dialog has resolved. */\n resolved?: string;\n /** Frame the dialog opens on. */\n openAt?: number;\n /** Frame the confirm button is pressed. */\n confirmAt?: number;\n};\n\n/**\n * The four states a dialog actually has: closed, open, confirming, resolved.\n *\n * They are driven by two frame numbers rather than by props a caller flips,\n * because the point of a video is the transition between states — a component\n * that only renders one of them makes the author animate it from outside, and\n * then two videos of the same dialog disagree about how it opens.\n */\nexport const ModalFlow = ({\n children,\n title = \"Deploy to production?\",\n body = \"This will promote the current preview build. It can be rolled back.\",\n confirm = \"Deploy\",\n cancel = \"Cancel\",\n resolved = \"Deployed\",\n openAt = 18,\n confirmAt = 66,\n}: ModalFlowProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const open = interpolate(frame, [openAt, openAt + 12], [0, 1], {easing: Easing.standard});\n const close = interpolate(frame, [confirmAt, confirmAt + 10], [1, 0], {easing: Easing.standard});\n const visible = Math.min(open, close);\n const pressed = interpolate(frame, [confirmAt - 3, confirmAt, confirmAt + 6], [0, 1, 0]);\n const settled = interpolate(frame, [confirmAt + 8, confirmAt + 20], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill>\n {children}\n <div\n aria-hidden\n style={{background: \"rgba(0,0,0,0.62)\", inset: 0, opacity: visible, position: \"absolute\"}}\n />\n <div\n style={{\n alignItems: \"center\",\n inset: 0,\n justifyContent: \"center\",\n display: \"flex\",\n opacity: visible,\n position: \"absolute\",\n }}\n >\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 24 * scale,\n boxShadow: `0 ${30 * scale}px ${70 * scale}px
|
|
3835
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type ModalFlowProps = {\n /** What sits behind the dialog. */\n children?: ReactNode;\n title?: string;\n body?: string;\n /** The primary button's label. */\n confirm?: string;\n cancel?: string;\n /** What the surface says once the dialog has resolved. */\n resolved?: string;\n /** Frame the dialog opens on. */\n openAt?: number;\n /** Frame the confirm button is pressed. */\n confirmAt?: number;\n};\n\n/**\n * The four states a dialog actually has: closed, open, confirming, resolved.\n *\n * They are driven by two frame numbers rather than by props a caller flips,\n * because the point of a video is the transition between states — a component\n * that only renders one of them makes the author animate it from outside, and\n * then two videos of the same dialog disagree about how it opens.\n */\nexport const ModalFlow = ({\n children,\n title = \"Deploy to production?\",\n body = \"This will promote the current preview build. It can be rolled back.\",\n confirm = \"Deploy\",\n cancel = \"Cancel\",\n resolved = \"Deployed\",\n openAt = 18,\n confirmAt = 66,\n}: ModalFlowProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const open = interpolate(frame, [openAt, openAt + 12], [0, 1], {easing: Easing.standard});\n const close = interpolate(frame, [confirmAt, confirmAt + 10], [1, 0], {easing: Easing.standard});\n const visible = Math.min(open, close);\n const pressed = interpolate(frame, [confirmAt - 3, confirmAt, confirmAt + 6], [0, 1, 0]);\n const settled = interpolate(frame, [confirmAt + 8, confirmAt + 20], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill>\n {children}\n <div\n aria-hidden\n style={{background: \"rgba(0,0,0,0.62)\", inset: 0, opacity: visible, position: \"absolute\"}}\n />\n <div\n style={{\n alignItems: \"center\",\n inset: 0,\n justifyContent: \"center\",\n display: \"flex\",\n opacity: visible,\n position: \"absolute\",\n }}\n >\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 24 * scale,\n boxShadow: `0 ${30 * scale}px ${70 * scale}px ${brand.colors.shadow}`,\n display: \"flex\",\n flexDirection: \"column\",\n gap: 20 * scale,\n padding: 48 * scale,\n transform: `translateY(${(1 - visible) * 24 * scale}px) scale(${0.97 + visible * 0.03})`,\n width: 900 * scale,\n }}\n >\n <strong\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.sans,\n fontSize: 44 * scale,\n fontWeight: 600,\n letterSpacing: \"-0.025em\",\n }}\n >\n {title}\n </strong>\n <p\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.sans,\n fontSize: 27 * scale,\n lineHeight: 1.5,\n margin: 0,\n }}\n >\n {body}\n </p>\n <div style={{display: \"flex\", gap: 14 * scale, justifyContent: \"flex-end\", marginTop: 12 * scale}}>\n <span\n style={{\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 12 * scale,\n color: brand.colors.muted,\n fontFamily: brand.typography.sans,\n fontSize: 26 * scale,\n padding: `${16 * scale}px ${28 * scale}px`,\n }}\n >\n {cancel}\n </span>\n <span\n style={{\n background: brand.colors.accent,\n borderRadius: 12 * scale,\n color: brand.colors.background,\n fontFamily: brand.typography.sans,\n fontSize: 26 * scale,\n fontWeight: 560,\n padding: `${16 * scale}px ${28 * scale}px`,\n // The press is the only motion on the button, and it is what\n // makes the dialog close read as caused rather than scheduled.\n transform: `scale(${1 - pressed * 0.04})`,\n }}\n >\n {confirm}\n </span>\n </div>\n </div>\n </div>\n\n {resolved ? (\n <div\n style={{\n alignItems: \"center\",\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 14 * scale,\n bottom: 60 * scale,\n color: brand.colors.foreground,\n display: \"flex\",\n fontFamily: brand.typography.sans,\n fontSize: 26 * scale,\n gap: 12 * scale,\n left: \"50%\",\n opacity: settled,\n padding: `${18 * scale}px ${28 * scale}px`,\n position: \"absolute\",\n transform: `translate(-50%, ${(1 - settled) * 16 * scale}px)`,\n }}\n >\n <span style={{background: brand.colors.accent, borderRadius: 999, height: 10 * scale, width: 10 * scale}} />\n {resolved}\n </div>\n ) : null}\n </Fill>\n );\n};\n",
|
|
3095
3836
|
"target": "videos/components/modal-flow/modal-flow.tsx"
|
|
3096
3837
|
},
|
|
3097
3838
|
{
|
|
3098
3839
|
"path": "components/modal-flow/modal-flow.preview.tsx",
|
|
3099
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ModalFlow} from \"./modal-flow\";\n\nconst Behind = () => (\n <div style={{background:
|
|
3840
|
+
"content": "import {useBrand} from \"odori\";\nimport {defineComponentPreview} from \"odori/preview\";\nimport {ModalFlow} from \"./modal-flow\";\n\nconst Behind = () => {\n const brand = useBrand();\n return (\n <div style={{background: brand.colors.background, inset: 0, position: \"absolute\"}}>\n <div\n style={{\n color: brand.colors.foreground,\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};\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",
|
|
3100
3841
|
"target": "videos/components/modal-flow/modal-flow.preview.tsx"
|
|
3101
3842
|
}
|
|
3102
3843
|
],
|
|
@@ -3131,6 +3872,48 @@
|
|
|
3131
3872
|
}
|
|
3132
3873
|
}
|
|
3133
3874
|
},
|
|
3875
|
+
{
|
|
3876
|
+
"name": "molten-flow",
|
|
3877
|
+
"description": "Domain-warped noise lit as a relief, so a highlight band travels the folds like liquid metal.",
|
|
3878
|
+
"registryDependencies": [],
|
|
3879
|
+
"files": [
|
|
3880
|
+
{
|
|
3881
|
+
"path": "components/molten-flow/molten-flow.tsx",
|
|
3882
|
+
"content": "import {Fill, useBrand, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * Liquid metal: warped noise lit as a surface rather than coloured as a field.\n *\n * The same domain-warped fbm as `noise-field`, but instead of mapping height to\n * colour, the height becomes a relief and a light grazes it. The highlight band\n * only appears where the slope faces the light, which is what separates metal\n * from smoke — a highlight that moves across a fold as the fold moves, rather\n * than a bright region that is simply bright.\n *\n * `shine` is the control worth having: it is the specular exponent, so low is\n * brushed pewter and high is a mirror catching one line of light.\n */\nexport type MoltenFlowProps = {\n /** Dark base and highlight. Defaults to the brand's background and accent. */\n colors?: [string, string];\n /** How fast the surface folds. */\n speed?: number;\n /** Size of the largest folds, in screens. */\n scale?: number;\n /** Specular sharpness. Low is brushed, high is chrome. */\n shine?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst field = defineShaderEffect({\n name: \"molten-flow\",\n uniforms: {\n speed: numberUniform(0.12),\n scale: numberUniform(2.5),\n shine: numberUniform(24),\n base: vec3Uniform([0.02, 0.02, 0.03]),\n light: vec3Uniform([0.85, 0.88, 0.95]),\n },\n fragmentShader: `\nuniform float speed;\nuniform float scale;\nuniform float shine;\nuniform vec3 base;\nuniform vec3 light;\n\nfloat value(vec2 p) {\n vec2 cell = floor(p);\n vec2 f = fract(p);\n f = f * f * (3.0 - 2.0 * f);\n float a = hash(cell);\n float b = hash(cell + vec2(1.0, 0.0));\n float c = hash(cell + vec2(0.0, 1.0));\n float d = hash(cell + vec2(1.0, 1.0));\n return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);\n}\n\nfloat fbm(vec2 p) {\n float sum = 0.0;\n float weight = 0.5;\n for (int i = 0; i < 5; i++) {\n sum += value(p) * weight;\n p = p * 2.03 + 17.3;\n weight *= 0.5;\n }\n return sum;\n}\n\n/* The warped surface, as a height. Factored out because the lighting needs to\n sample it three times to know which way it leans. */\nfloat height(vec2 uv, float t) {\n vec2 q = vec2(fbm(uv + t), fbm(uv + vec2(4.7, 2.1) - t * 0.8));\n return fbm(uv + q * 2.2);\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n uv.x *= resolution.x / resolution.y;\n uv *= scale;\n float t = seconds * speed;\n\n /* A finite-difference normal. The epsilon is in noise space, not pixels, so\n the shading does not change character with the output size. */\n float e = 0.02;\n float h = height(uv, t);\n float hx = height(uv + vec2(e, 0.0), t);\n float hy = height(uv + vec2(0.0, e), t);\n vec3 normal = normalize(vec3((h - hx) / e, (h - hy) / e, 1.2));\n\n vec3 lightDirection = normalize(vec3(-0.45, 0.65, 0.55));\n float diffuse = max(dot(normal, lightDirection), 0.0);\n /* Blinn half-vector against a viewer straight on, which is enough: the\n surface is flat, only the relief pretends otherwise. */\n vec3 half_ = normalize(lightDirection + vec3(0.0, 0.0, 1.0));\n float specular = pow(max(dot(normal, half_), 0.0), shine);\n\n vec3 colour = base + light * (diffuse * 0.14 + specular * 0.85);\n odoriColour = vec4(colour, 1.0);\n}`,\n});\n\nexport const MoltenFlow = ({colors, speed = 0.12, scale = 2.5, shine = 24}: MoltenFlowProps) => {\n const brand = useBrand();\n const {width, height} = useVideo();\n const [base, light] = colors ?? [brand.colors.background, brand.colors.accent];\n\n return (\n <EffectSurface effects={[field({speed, scale, shine, base: hexToRgb(base), light: hexToRgb(light)})]}>\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n",
|
|
3883
|
+
"target": "videos/components/molten-flow/molten-flow.tsx"
|
|
3884
|
+
},
|
|
3885
|
+
{
|
|
3886
|
+
"path": "components/molten-flow/molten-flow.preview.tsx",
|
|
3887
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {MoltenFlow} from \"./molten-flow\";\n\nexport default defineComponentPreview({\n title: \"Molten flow\",\n category: \"Backgrounds\",\n description: \"Domain-warped noise lit as a relief, so a highlight band travels the folds like liquid metal.\",\n component: MoltenFlow,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n speed: {type: \"number\", defaultValue: 0.12, min: 0, max: 0.6, step: 0.02},\n scale: {type: \"number\", defaultValue: 2.5, min: 1, max: 8, step: 0.5},\n shine: {type: \"number\", defaultValue: 24, min: 4, max: 96, step: 4},\n },\n examples: [\n {name: \"Chrome\", props: {colors: [\"#050507\", \"#f4f6fa\"], shine: 48}},\n {name: \"Molten\", props: {colors: [\"#160804\", \"#ff9a3d\"], shine: 12, speed: 0.18}},\n {name: \"Brand\", props: {}},\n ],\n});\n",
|
|
3888
|
+
"target": "videos/components/molten-flow/molten-flow.preview.tsx"
|
|
3889
|
+
}
|
|
3890
|
+
],
|
|
3891
|
+
"meta": {
|
|
3892
|
+
"kind": "component",
|
|
3893
|
+
"family": "Backgrounds",
|
|
3894
|
+
"namespaced": "@odori/molten-flow",
|
|
3895
|
+
"contract": {
|
|
3896
|
+
"aspectRatios": [
|
|
3897
|
+
"16:9",
|
|
3898
|
+
"9:16",
|
|
3899
|
+
"1:1"
|
|
3900
|
+
],
|
|
3901
|
+
"recommendedDurationInFrames": 180,
|
|
3902
|
+
"minimumDurationInFrames": 30,
|
|
3903
|
+
"entranceFrames": 0,
|
|
3904
|
+
"exitFrames": 0,
|
|
3905
|
+
"contentLimits": {},
|
|
3906
|
+
"reducedMotion": "set speed to 0 and the surface holds one lit still",
|
|
3907
|
+
"requires": {
|
|
3908
|
+
"fonts": [],
|
|
3909
|
+
"audio": [],
|
|
3910
|
+
"graphics": [
|
|
3911
|
+
"webgl2"
|
|
3912
|
+
]
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
},
|
|
3134
3917
|
{
|
|
3135
3918
|
"name": "noise-field",
|
|
3136
3919
|
"description": "Domain-warped fractal noise that folds through itself, from soft cloud to hard contour.",
|
|
@@ -3143,13 +3926,13 @@
|
|
|
3143
3926
|
},
|
|
3144
3927
|
{
|
|
3145
3928
|
"path": "components/noise-field/noise-field.preview.tsx",
|
|
3146
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {NoiseField} from \"./noise-field\";\n\nexport default defineComponentPreview({\n title: \"Noise field\",\n category: \"
|
|
3929
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {NoiseField} from \"./noise-field\";\n\nexport default defineComponentPreview({\n title: \"Noise field\",\n category: \"Backgrounds\",\n description: \"Domain-warped fractal noise that folds through itself, from soft cloud to hard contour.\",\n component: NoiseField,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n speed: {type: \"number\", defaultValue: 0.25, min: 0, max: 1.5, step: 0.05},\n scale: {type: \"number\", defaultValue: 3, min: 1, max: 10, step: 0.5},\n contrast: {type: \"number\", defaultValue: 0.3, min: 0, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Contour\", props: {contrast: 1, scale: 4}},\n {name: \"Tide\", props: {colors: [\"#04121a\", \"#4fd1c5\"], scale: 2, speed: 0.15}},\n ],\n});\n",
|
|
3147
3930
|
"target": "videos/components/noise-field/noise-field.preview.tsx"
|
|
3148
3931
|
}
|
|
3149
3932
|
],
|
|
3150
3933
|
"meta": {
|
|
3151
3934
|
"kind": "component",
|
|
3152
|
-
"family": "
|
|
3935
|
+
"family": "Backgrounds",
|
|
3153
3936
|
"namespaced": "@odori/noise-field",
|
|
3154
3937
|
"contract": {
|
|
3155
3938
|
"aspectRatios": [
|
|
@@ -3263,6 +4046,209 @@
|
|
|
3263
4046
|
}
|
|
3264
4047
|
}
|
|
3265
4048
|
},
|
|
4049
|
+
{
|
|
4050
|
+
"name": "orbit-gallery",
|
|
4051
|
+
"description": "Tiles drifting past the front of a shallow 3D ring.",
|
|
4052
|
+
"registryDependencies": [],
|
|
4053
|
+
"files": [
|
|
4054
|
+
{
|
|
4055
|
+
"path": "components/orbit-gallery/orbit-gallery.tsx",
|
|
4056
|
+
"content": "import {Children, type ReactNode} from \"react\";\nimport {Fill, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type OrbitGalleryProps = {\n /** The tiles on the ring. Four to eight reads best. */\n children?: ReactNode;\n /** Ring radius in design pixels. */\n radius?: number;\n /** Rotation in degrees per frame. Slow is the point. */\n speed?: number;\n /** Camera tilt in degrees, looking slightly down onto the ring. */\n tilt?: number;\n};\n\n/**\n * Tiles on a shallow 3D ring, drifting past the front.\n *\n * The ring is deliberately shallow — the radius is small next to the\n * perspective distance — so depth reads as a gentle lean rather than a\n * carousel trick. Every tile is billboarded back toward the camera after\n * being placed, because footage viewed edge-on is footage wasted; position\n * on the ring carries the depth, and scale and brightness carry the focus.\n *\n * Nothing is stateful: each tile's angle is the frame times the speed, so\n * the front of the ring is a pure function of the clock and the rotation\n * survives scrubbing and parallel render chunks untouched.\n */\nexport const OrbitGallery = ({children, radius = 430, speed = 0.45, tilt = 8}: OrbitGalleryProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const supplied = Children.toArray(children);\n const tiles =\n supplied.length > 0\n ? supplied\n : Array.from({length: 6}, (_, index) => (\n <div\n key={index}\n style={{\n background: `linear-gradient(${125 + index * 40}deg, ${brand.colors.accent} -60%, ${brand.colors.surface} ${52 + (index % 3) * 14}%)`,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 18 * scale,\n height: 250 * scale,\n width: 380 * scale,\n }}\n />\n ));\n const count = tiles.length;\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", perspective: 1700 * scale}}>\n <div style={{position: \"relative\", transform: `rotateX(${-tilt}deg)`, transformStyle: \"preserve-3d\"}}>\n {tiles.map((tile, index) => {\n const angle = (index / count) * 360 - frame * speed;\n // How close this tile is to facing the viewer, 1 at dead front.\n const front = (Math.cos((angle * Math.PI) / 180) + 1) / 2;\n return (\n <div\n key={index}\n style={{\n filter: `brightness(${0.55 + front * 0.45})`,\n left: \"50%\",\n opacity: 0.6 + front * 0.4,\n position: \"absolute\",\n top: \"50%\",\n transform: `translate(-50%, -50%) rotateY(${angle}deg) translateZ(${radius * scale}px) rotateY(${-angle}deg) scale(${0.94 + front * 0.12})`,\n }}\n >\n {tile}\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
|
|
4057
|
+
"target": "videos/components/orbit-gallery/orbit-gallery.tsx"
|
|
4058
|
+
},
|
|
4059
|
+
{
|
|
4060
|
+
"path": "components/orbit-gallery/orbit-gallery.preview.tsx",
|
|
4061
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {placeholderImage} from \"odori\";\nimport {OrbitGallery} from \"./orbit-gallery\";\n\nexport default defineComponentPreview({\n title: \"Orbit gallery\",\n category: \"Media/Footage\",\n description: \"Tiles drifting past the front of a shallow 3D ring.\",\n component: OrbitGallery,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n radius: {type: \"number\", defaultValue: 430, min: 240, max: 700, step: 10},\n speed: {type: \"number\", defaultValue: 0.45, min: 0.1, max: 2, step: 0.05},\n tilt: {type: \"number\", defaultValue: 8, min: -20, max: 20},\n },\n examples: [\n {name: \"Default\", props: {}},\n {\n name: \"Footage\",\n props: {\n children: Array.from({length: 5}, (_, index) => (\n <img\n key={index}\n src={placeholderImage({label: `shot ${index + 1}`, seed: `orbit-${index}`})}\n alt=\"\"\n style={{borderRadius: 18, display: \"block\", height: 250, objectFit: \"cover\", width: 380}}\n />\n )),\n speed: 0.6,\n },\n },\n {name: \"Tight ring\", props: {radius: 300, speed: 0.9, tilt: 14}},\n ],\n});\n",
|
|
4062
|
+
"target": "videos/components/orbit-gallery/orbit-gallery.preview.tsx"
|
|
4063
|
+
}
|
|
4064
|
+
],
|
|
4065
|
+
"meta": {
|
|
4066
|
+
"kind": "component",
|
|
4067
|
+
"family": "Media",
|
|
4068
|
+
"namespaced": "@odori/orbit-gallery",
|
|
4069
|
+
"contract": {
|
|
4070
|
+
"aspectRatios": [
|
|
4071
|
+
"16:9",
|
|
4072
|
+
"9:16",
|
|
4073
|
+
"1:1"
|
|
4074
|
+
],
|
|
4075
|
+
"recommendedDurationInFrames": 240,
|
|
4076
|
+
"minimumDurationInFrames": 90,
|
|
4077
|
+
"entranceFrames": 0,
|
|
4078
|
+
"exitFrames": 0,
|
|
4079
|
+
"contentLimits": {
|
|
4080
|
+
"children": 8
|
|
4081
|
+
},
|
|
4082
|
+
"reducedMotion": "the ring holds still with one tile at the front",
|
|
4083
|
+
"requires": {
|
|
4084
|
+
"fonts": [],
|
|
4085
|
+
"audio": []
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
},
|
|
4090
|
+
{
|
|
4091
|
+
"name": "orbit-text",
|
|
4092
|
+
"description": "A phrase circles slowly, badge-style, around a centerpiece.",
|
|
4093
|
+
"registryDependencies": [],
|
|
4094
|
+
"files": [
|
|
4095
|
+
{
|
|
4096
|
+
"path": "components/orbit-text/orbit-text.tsx",
|
|
4097
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type OrbitTextProps = {\n text: string;\n /** Ring radius in design pixels. */\n radius?: number;\n /** Degrees of rotation per frame. Negative runs counter-clockwise. */\n speed?: number;\n /** Glyph size in design pixels. */\n size?: number;\n /** Rendered at the center of the ring. Defaults to a small accent dot. */\n children?: ReactNode;\n};\n\n/**\n * A phrase set around a circle, turning slowly — a badge or seal beat. The\n * text is repeated with a separator until the ring is comfortably full, then\n * the glyphs are spread evenly so the circle always closes without a gap.\n * Rotation is frame times speed, so the seal turns at the same rate in the\n * preview and in every render chunk.\n */\nexport const OrbitText = ({text, radius = 240, speed = 0.25, size = 36, children}: OrbitTextProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n // Repeat a short phrase until the ring's circumference is roughly covered.\n const capacity = Math.max([...text].length, Math.floor((2 * Math.PI * radius) / (size * 0.72)));\n let ring = text;\n while ([...ring].length + [...text].length + 3 <= capacity) {\n ring += ` • ${text}`;\n }\n const glyphs = [...`${ring} • `];\n const rotation = frame * speed;\n const entrance = interpolate(frame, [0, 24], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\"}}>\n <div style={{height: radius * 2 * scale, opacity: entrance, position: \"relative\", width: radius * 2 * scale}}>\n {glyphs.map((glyph, index) => (\n <span\n key={`${glyph}-${index}`}\n style={{\n fontSize: size * scale,\n fontWeight: 550,\n left: \"50%\",\n letterSpacing: \"0.06em\",\n position: \"absolute\",\n top: \"50%\",\n transform: `translate(-50%, -50%) rotate(${(index * 360) / glyphs.length + rotation}deg) translateY(${-radius * scale}px)`,\n whiteSpace: \"pre\",\n }}\n >\n {glyph}\n </span>\n ))}\n <div\n style={{\n alignItems: \"center\",\n display: \"flex\",\n inset: 0,\n justifyContent: \"center\",\n position: \"absolute\",\n }}\n >\n {children ?? (\n <div\n style={{\n background: brand.colors.accent,\n borderRadius: \"50%\",\n height: 18 * scale,\n width: 18 * scale,\n }}\n />\n )}\n </div>\n </div>\n </Fill>\n );\n};\n",
|
|
4098
|
+
"target": "videos/components/orbit-text/orbit-text.tsx"
|
|
4099
|
+
},
|
|
4100
|
+
{
|
|
4101
|
+
"path": "components/orbit-text/orbit-text.preview.tsx",
|
|
4102
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {OrbitText} from \"./orbit-text\";\n\nexport default defineComponentPreview({\n title: \"Orbit text\",\n category: \"Typography/Titles\",\n description: \"A phrase circles slowly, badge-style, around a centerpiece.\",\n component: OrbitText,\n canvas: {width: 1080, height: 1080, duration: \"6s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"SHIPPED WITH CARE\", maxLength: 32},\n radius: {type: \"number\", defaultValue: 240, min: 100, max: 420},\n speed: {type: \"number\", defaultValue: 0.25, min: -2, max: 2, step: 0.05},\n size: {type: \"number\", defaultValue: 36, min: 16, max: 72},\n },\n examples: [\n {name: \"Default\", props: {text: \"SHIPPED WITH CARE\"}},\n {name: \"Tight seal\", props: {text: \"EST. 2026\", radius: 150, size: 28, speed: 0.5}},\n {name: \"Reverse\", props: {text: \"AROUND WE GO\", speed: -0.4}},\n ],\n});\n",
|
|
4103
|
+
"target": "videos/components/orbit-text/orbit-text.preview.tsx"
|
|
4104
|
+
}
|
|
4105
|
+
],
|
|
4106
|
+
"meta": {
|
|
4107
|
+
"kind": "component",
|
|
4108
|
+
"family": "Typography",
|
|
4109
|
+
"namespaced": "@odori/orbit-text",
|
|
4110
|
+
"contract": {
|
|
4111
|
+
"aspectRatios": [
|
|
4112
|
+
"16:9",
|
|
4113
|
+
"9:16",
|
|
4114
|
+
"1:1"
|
|
4115
|
+
],
|
|
4116
|
+
"recommendedDurationInFrames": 180,
|
|
4117
|
+
"minimumDurationInFrames": 60,
|
|
4118
|
+
"entranceFrames": 24,
|
|
4119
|
+
"exitFrames": 0,
|
|
4120
|
+
"contentLimits": {
|
|
4121
|
+
"text": 32
|
|
4122
|
+
},
|
|
4123
|
+
"reducedMotion": "the ring fades in and holds still instead of rotating",
|
|
4124
|
+
"requires": {
|
|
4125
|
+
"fonts": [
|
|
4126
|
+
"sans"
|
|
4127
|
+
],
|
|
4128
|
+
"audio": []
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
},
|
|
4133
|
+
{
|
|
4134
|
+
"name": "parallax-card",
|
|
4135
|
+
"description": "A screenshot on a gently tilting plane, with the light and shadow that sell the depth.",
|
|
4136
|
+
"registryDependencies": [],
|
|
4137
|
+
"files": [
|
|
4138
|
+
{
|
|
4139
|
+
"path": "components/parallax-card/parallax-card.tsx",
|
|
4140
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Fill, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type ParallaxCardProps = {\n /** What rides the plane: a screenshot, a chart, any block of UI. */\n children?: ReactNode;\n /** Maximum tilt in degrees. Small on purpose; past a few degrees the eye reads a broken window, not a floating object. */\n tilt?: number;\n /** Multiplier on the drift rate. */\n speed?: number;\n /** Strength of the travelling sheen, 0 to 1. */\n sheen?: number;\n /** Corner radius in design pixels. */\n radius?: number;\n};\n\n/**\n * A surface floating on a slowly tilting plane.\n *\n * The tilt is what tells the eye this is an object in space and not a flat\n * paste-in. It is scripted rather than pointed at: each axis follows a sine\n * path, and the two periods are incommensurate so the plane wanders without\n * visibly repeating — and identically on every render.\n *\n * Light keeps the story honest. The sheen crosses the face exactly as the\n * plane turns through it, and the floor shadow slides the opposite way,\n * because that is what a fixed light overhead would do. Cut either and the\n * tilt reads as a transform; together they read as a room.\n */\nexport const ParallaxCard = ({children, tilt = 4, speed = 1, sheen = 0.35, radius = 24}: ParallaxCardProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const t = frame * speed;\n const rotateY = Math.sin(t / 46) * tilt;\n const rotateX = Math.sin(t / 67 + 2.1) * tilt * 0.7;\n // How far the plane leans, -1 to 1, driving the light and the shadow.\n const lean = tilt === 0 ? 0 : rotateY / tilt;\n const sheenAt = 50 - lean * 42;\n\n // A quiet stand-in so the component shows its move before real content arrives.\n const fallback = (\n <div\n style={{\n alignContent: \"end\",\n background: `linear-gradient(150deg, color-mix(in srgb, ${brand.colors.accent} 28%, ${brand.colors.surface}), ${brand.colors.surface})`,\n display: \"grid\",\n gap: 16 * scale,\n height: 580 * scale,\n padding: 44 * scale,\n width: 960 * scale,\n }}\n >\n <div style={{background: brand.colors.background, borderRadius: 10 * scale, height: 30 * scale, opacity: 0.9, width: \"44%\"}} />\n <div style={{background: brand.colors.background, borderRadius: 10 * scale, height: 18 * scale, opacity: 0.55, width: \"68%\"}} />\n <div style={{background: brand.colors.background, borderRadius: 10 * scale, height: 18 * scale, opacity: 0.55, width: \"58%\"}} />\n </div>\n );\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\"}}>\n <div style={{perspective: 1500 * scale, position: \"relative\"}}>\n {/* The floor shadow, moving against the lean the way a grounded shadow does. */}\n <div\n style={{\n background: `color-mix(in srgb, ${brand.colors.foreground} 30%, transparent)`,\n borderRadius: \"50%\",\n bottom: -52 * scale,\n filter: `blur(${28 * scale}px)`,\n height: 54 * scale,\n left: \"8%\",\n position: \"absolute\",\n right: \"8%\",\n transform: `translateX(${-lean * 34 * scale}px)`,\n }}\n />\n <div\n style={{\n border: `1px solid ${brand.colors.border}`,\n borderRadius: radius * scale,\n boxShadow: `0 ${20 * scale}px ${56 * scale}px color-mix(in srgb, ${brand.colors.foreground} 14%, transparent)`,\n overflow: \"hidden\",\n position: \"relative\",\n transform: `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`,\n }}\n >\n {children ?? fallback}\n {/* The sheen, riding the tilt rather than a clock of its own. */}\n <div\n style={{\n background: `linear-gradient(105deg, transparent ${sheenAt - 24}%, rgba(255, 255, 255, ${0.45 * sheen}) ${sheenAt}%, transparent ${sheenAt + 24}%)`,\n inset: 0,\n position: \"absolute\",\n }}\n />\n </div>\n </div>\n </Fill>\n );\n};\n",
|
|
4141
|
+
"target": "videos/components/parallax-card/parallax-card.tsx"
|
|
4142
|
+
},
|
|
4143
|
+
{
|
|
4144
|
+
"path": "components/parallax-card/parallax-card.preview.tsx",
|
|
4145
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ParallaxCard} from \"./parallax-card\";\n\nconst Screenshot = () => (\n <div\n style={{\n background: \"linear-gradient(160deg, #17233a, #0b1120)\",\n display: \"grid\",\n gap: 22,\n height: 620,\n padding: 48,\n width: 1000,\n }}\n >\n <div style={{display: \"flex\", gap: 10}}>\n {[\"#ff5f57\", \"#febc2e\", \"#28c840\"].map((dot) => (\n <span key={dot} style={{background: dot, borderRadius: \"50%\", height: 16, width: 16}} />\n ))}\n </div>\n <div style={{background: \"#f5f7fb\", borderRadius: 12, height: 36, opacity: 0.92, width: \"42%\"}} />\n <div style={{background: \"#8fa3c8\", borderRadius: 10, height: 20, opacity: 0.6, width: \"70%\"}} />\n <div style={{background: \"#8fa3c8\", borderRadius: 10, height: 20, opacity: 0.6, width: \"58%\"}} />\n <div style={{background: \"linear-gradient(120deg, #2f49d0, #7aa2ff)\", borderRadius: 16, height: 220, marginTop: 12}} />\n </div>\n);\n\nexport default defineComponentPreview({\n title: \"Parallax card\",\n category: \"Media/Footage\",\n description: \"A screenshot on a gently tilting plane, with the light and shadow that sell the depth.\",\n component: ParallaxCard,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n tilt: {type: \"number\", defaultValue: 4, min: 0, max: 12},\n speed: {type: \"number\", defaultValue: 1, min: 0.25, max: 3},\n sheen: {type: \"number\", defaultValue: 0.35, min: 0, max: 1},\n radius: {type: \"number\", defaultValue: 24, min: 0, max: 60},\n },\n examples: [\n {name: \"Screenshot\", props: {children: <Screenshot />}},\n {name: \"More presence\", props: {children: <Screenshot />, tilt: 7, sheen: 0.6}},\n {name: \"Nearly still\", props: {tilt: 2, speed: 0.6, sheen: 0.15}},\n ],\n});\n",
|
|
4146
|
+
"target": "videos/components/parallax-card/parallax-card.preview.tsx"
|
|
4147
|
+
}
|
|
4148
|
+
],
|
|
4149
|
+
"meta": {
|
|
4150
|
+
"kind": "component",
|
|
4151
|
+
"family": "Media",
|
|
4152
|
+
"namespaced": "@odori/parallax-card",
|
|
4153
|
+
"contract": {
|
|
4154
|
+
"aspectRatios": [
|
|
4155
|
+
"16:9",
|
|
4156
|
+
"9:16",
|
|
4157
|
+
"1:1"
|
|
4158
|
+
],
|
|
4159
|
+
"recommendedDurationInFrames": 180,
|
|
4160
|
+
"minimumDurationInFrames": 60,
|
|
4161
|
+
"entranceFrames": 0,
|
|
4162
|
+
"exitFrames": 0,
|
|
4163
|
+
"contentLimits": {},
|
|
4164
|
+
"reducedMotion": "the plane holds level with a static sheen and shadow",
|
|
4165
|
+
"requires": {
|
|
4166
|
+
"fonts": [],
|
|
4167
|
+
"audio": []
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
},
|
|
4172
|
+
{
|
|
4173
|
+
"name": "pixel-wipe",
|
|
4174
|
+
"description": "A cover of solid blocks clearing one by one, a wipe that travels but crackles.",
|
|
4175
|
+
"registryDependencies": [],
|
|
4176
|
+
"files": [
|
|
4177
|
+
{
|
|
4178
|
+
"path": "components/pixel-wipe/pixel-wipe.tsx",
|
|
4179
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Fill, random, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\nexport type PixelWipeProps = {\n /** What is revealed as the blocks clear. */\n children?: ReactNode;\n /** Frame the wipe begins. */\n at?: number;\n /** How long the last block takes to clear. */\n durationInFrames?: number;\n /** Block size in design pixels. Capped so the cover stays cheap. */\n block?: number;\n /** Colour of the cover. Defaults to the brand background. */\n color?: string;\n /** Which way the wipe travels. */\n direction?: \"right\" | \"left\" | \"down\" | \"up\";\n};\n\n/**\n * A scene arriving through a seeded block dissolve.\n *\n * A cover of solid blocks sits over the content and each block disappears on\n * its own frame. The order is a diagonal position plus seeded noise, so the\n * wipe clearly travels — the eye can follow it — but the edge crackles instead\n * of drawing a straight line, which is what separates this from a plain wipe\n * with a jagged costume. The same seeds fire in every render worker, so the\n * crackle is identical in the preview and the export.\n */\nexport const PixelWipe = ({\n children,\n at = 0,\n durationInFrames = 30,\n block = 64,\n color,\n direction = \"right\",\n}: PixelWipeProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n\n const plate = color ?? brand.colors.background;\n\n /* The grid, capped: a tiny block size on a large canvas would mean thousands\n of divs, and past a few hundred the crackle reads the same anyway. */\n let side = Math.max(8, block * scale);\n if ((width / side) * (height / side) > 600) side = Math.sqrt((width * height) / 600);\n const cols = Math.ceil(width / side);\n const rows = Math.ceil(height / side);\n const done = frame >= at + durationInFrames;\n\n const blocks: ReactNode[] = [];\n if (!done) {\n for (let row = 0; row < rows; row += 1) {\n for (let col = 0; col < cols; col += 1) {\n /* Where this block sits along the travel, 0 first to clear. The cross\n axis leans in at half weight, which slants the front into a diagonal\n rather than a flat bar. */\n const main =\n direction === \"right\"\n ? col / Math.max(1, cols - 1)\n : direction === \"left\"\n ? 1 - col / Math.max(1, cols - 1)\n : direction === \"down\"\n ? row / Math.max(1, rows - 1)\n : 1 - row / Math.max(1, rows - 1);\n const cross =\n direction === \"right\" || direction === \"left\"\n ? row / Math.max(1, rows - 1)\n : col / Math.max(1, cols - 1);\n const along = (main + cross * 0.5) / 1.5;\n\n /* Two thirds order, one third crackle. Each block clears exactly once,\n on a frame that is a pure function of its position and seed. */\n const jitter = random([\"pixel-wipe\", row, col]);\n const clearFrame = at + (along * 0.66 + jitter * 0.34) * durationInFrames;\n if (frame >= clearFrame) continue;\n\n blocks.push(\n <div\n key={`${row}-${col}`}\n style={{\n background: plate,\n height: Math.ceil(side) + 1,\n left: col * side,\n position: \"absolute\",\n top: row * side,\n width: Math.ceil(side) + 1,\n }}\n />,\n );\n }\n }\n }\n\n return (\n <Fill>\n {children}\n {blocks.length > 0 ? <Fill aria-hidden>{blocks}</Fill> : null}\n </Fill>\n );\n};\n",
|
|
4180
|
+
"target": "videos/components/pixel-wipe/pixel-wipe.tsx"
|
|
4181
|
+
},
|
|
4182
|
+
{
|
|
4183
|
+
"path": "components/pixel-wipe/pixel-wipe.preview.tsx",
|
|
4184
|
+
"content": "import {useBrand} from \"odori\";\nimport {defineComponentPreview} from \"odori/preview\";\nimport {PixelWipe} from \"./pixel-wipe\";\n\nconst Behind = () => {\n const brand = useBrand();\n return (\n <div\n style={{\n alignItems: \"center\",\n background: brand.colors.background,\n color: brand.colors.foreground,\n display: \"flex\",\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 96,\n inset: 0,\n justifyContent: \"center\",\n letterSpacing: \"-0.04em\",\n position: \"absolute\",\n }}\n >\n Block by block.\n </div>\n );\n};\n\nexport default defineComponentPreview({\n title: \"Pixel wipe\",\n category: \"Motion/Transitions\",\n description: \"A cover of solid blocks clearing one by one, a wipe that travels but crackles.\",\n component: PixelWipe,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n at: {type: \"number\", defaultValue: 0, min: 0, max: 60},\n durationInFrames: {type: \"number\", defaultValue: 30, min: 8, max: 90},\n block: {type: \"number\", defaultValue: 64, min: 24, max: 240, step: 8},\n direction: {type: \"select\", defaultValue: \"right\", options: [\"right\", \"left\", \"down\", \"up\"]},\n },\n examples: [\n {name: \"Default\", props: {children: <Behind />}},\n {name: \"Coarse, downward\", props: {children: <Behind />, block: 160, direction: \"down\"}},\n {name: \"Fine and slow\", props: {children: <Behind />, block: 32, durationInFrames: 60}},\n ],\n});\n",
|
|
4185
|
+
"target": "videos/components/pixel-wipe/pixel-wipe.preview.tsx"
|
|
4186
|
+
}
|
|
4187
|
+
],
|
|
4188
|
+
"meta": {
|
|
4189
|
+
"kind": "component",
|
|
4190
|
+
"family": "Motion",
|
|
4191
|
+
"namespaced": "@odori/pixel-wipe",
|
|
4192
|
+
"contract": {
|
|
4193
|
+
"aspectRatios": [
|
|
4194
|
+
"16:9",
|
|
4195
|
+
"9:16",
|
|
4196
|
+
"1:1"
|
|
4197
|
+
],
|
|
4198
|
+
"recommendedDurationInFrames": 60,
|
|
4199
|
+
"minimumDurationInFrames": 30,
|
|
4200
|
+
"entranceFrames": 30,
|
|
4201
|
+
"exitFrames": 0,
|
|
4202
|
+
"contentLimits": {},
|
|
4203
|
+
"reducedMotion": "the cover is gone from the first frame",
|
|
4204
|
+
"requires": {
|
|
4205
|
+
"fonts": [],
|
|
4206
|
+
"audio": []
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
},
|
|
4211
|
+
{
|
|
4212
|
+
"name": "pointer-trail",
|
|
4213
|
+
"description": "A scripted pointer that leaves a fading trail along its path.",
|
|
4214
|
+
"registryDependencies": [],
|
|
4215
|
+
"files": [
|
|
4216
|
+
{
|
|
4217
|
+
"path": "components/pointer-trail/pointer-trail.tsx",
|
|
4218
|
+
"content": "import type {ReactNode} from \"react\";\nimport {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\nexport type PointerTrailProps = {\n /** Stops the pointer visits in order, as percentages of the frame. */\n waypoints?: Array<{x: number; y: number}>;\n /** Frames the pointer rests at each waypoint before leaving. */\n dwellFrames?: number;\n /** Frames each leg of travel takes. */\n travelFrames?: number;\n /** How many recent frames of motion the trail covers. */\n trailLength?: number;\n /** Trail colour. Defaults to the brand accent. */\n color?: string;\n /** The surface the pointer moves over. */\n children?: ReactNode;\n};\n\n/**\n * Where the pointer is on any frame, from the authored waypoints alone. The\n * trail below asks this same function about earlier frames, which is what\n * keeps the whole component a pure function of the clock: there is no\n * position history to accumulate, so scrubbing and parallel render workers\n * cannot disagree about where the pointer has been.\n */\nconst positionAt = (\n waypoints: Array<{x: number; y: number}>,\n dwellFrames: number,\n travelFrames: number,\n frame: number,\n): {x: number; y: number} => {\n if (waypoints.length === 0) return {x: 50, y: 50};\n if (waypoints.length === 1) return waypoints[0];\n const legFrames = dwellFrames + travelFrames;\n const leg = Math.min(Math.max(0, Math.floor(frame / legFrames)), waypoints.length - 2);\n const from = waypoints[leg];\n const to = waypoints[leg + 1];\n const progress = interpolate(frame - leg * legFrames, [dwellFrames, legFrames], [0, 1], {easing: Easing.standard});\n return {x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress};\n};\n\n/**\n * A pointer that shows where it has just been.\n *\n * A cursor alone reads as the present tense; the fading trail behind it adds\n * the last half second, which is what makes a fast gesture legible at a\n * glance instead of asking the viewer to have been watching. Dots taper and\n * fade with age, and during a dwell they gather under the pointer and\n * disappear, so a resting cursor looks at rest.\n */\nexport const PointerTrail = ({\n waypoints = [\n {x: 72, y: 74},\n {x: 30, y: 34},\n {x: 62, y: 30},\n {x: 44, y: 62},\n ],\n dwellFrames = 12,\n travelFrames = 26,\n trailLength = 14,\n color,\n children,\n}: PointerTrailProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n\n const tint = color ?? brand.colors.accent;\n const size = 30 * scale;\n const head = positionAt(waypoints, dwellFrames, travelFrames, frame);\n const trail = Array.from({length: trailLength}, (_, index) => {\n const age = (index + 1) / (trailLength + 1);\n const sample = positionAt(waypoints, dwellFrames, travelFrames, frame - (index + 1));\n return {age, ...sample};\n });\n\n return (\n <Fill>\n {children}\n {trail.map((dot, index) => (\n <span\n key={index}\n aria-hidden\n style={{\n background: tint,\n borderRadius: 999,\n filter: `blur(${1.5 * scale}px)`,\n height: size * 0.5 * (1 - dot.age),\n left: (dot.x / 100) * width,\n opacity: 0.55 * (1 - dot.age),\n position: \"absolute\",\n top: (dot.y / 100) * height,\n transform: \"translate(-50%, -50%)\",\n width: size * 0.5 * (1 - dot.age),\n }}\n />\n ))}\n <div\n aria-hidden\n style={{\n height: size,\n left: (head.x / 100) * width,\n position: \"absolute\",\n top: (head.y / 100) * height,\n // The tip is the anchor, the way a real pointer's hotspot is.\n transform: \"translate(-2%, -2%)\",\n width: size,\n }}\n >\n <svg\n viewBox=\"0 0 24 24\"\n style={{\n display: \"block\",\n filter: `drop-shadow(0 ${2 * scale}px ${6 * scale}px rgba(0,0,0,0.5))`,\n height: \"100%\",\n width: \"100%\",\n }}\n >\n <path\n d=\"M5 2.5 L5 19 L9.2 15.1 L11.9 21.2 L14.8 19.9 L12.1 13.9 L18 13.6 Z\"\n fill={brand.colors.foreground}\n stroke={brand.colors.background}\n strokeWidth={1.4}\n strokeLinejoin=\"round\"\n />\n </svg>\n </div>\n </Fill>\n );\n};\n",
|
|
4219
|
+
"target": "videos/components/pointer-trail/pointer-trail.tsx"
|
|
4220
|
+
},
|
|
4221
|
+
{
|
|
4222
|
+
"path": "components/pointer-trail/pointer-trail.preview.tsx",
|
|
4223
|
+
"content": "import {useBrand} from \"odori\";\nimport {defineComponentPreview} from \"odori/preview\";\nimport {PointerTrail} from \"./pointer-trail\";\n\n/** A plain surface, so the fixture shows the pointer rather than a product. */\nconst Surface = () => {\n const brand = useBrand();\n return (\n <div style={{background: brand.colors.background, inset: 0, position: \"absolute\"}}>\n {[\n {label: \"Search\", left: 300, top: 260},\n {label: \"Library\", left: 1080, top: 260},\n {label: \"Recent exports\", left: 300, top: 620},\n ].map((card) => (\n <div\n key={card.label}\n style={{\n border: \"1px solid #1f1f1f\",\n borderRadius: 16,\n color: brand.colors.foreground,\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 38,\n left: card.left,\n padding: \"26px 38px\",\n position: \"absolute\",\n top: card.top,\n width: 540,\n }}\n >\n {card.label}\n </div>\n ))}\n </div>\n );\n};\n\nexport default defineComponentPreview({\n title: \"Pointer trail\",\n category: \"Interface/Controls\",\n description: \"A scripted pointer that leaves a fading trail along its path.\",\n component: PointerTrail,\n canvas: {width: 1920, height: 1080, duration: \"5s\"},\n controls: {\n dwellFrames: {type: \"number\", defaultValue: 12, min: 0, max: 60},\n travelFrames: {type: \"number\", defaultValue: 26, min: 8, max: 90},\n trailLength: {type: \"number\", defaultValue: 14, min: 4, max: 30},\n },\n examples: [\n {\n name: \"Default\",\n props: {\n children: <Surface />,\n waypoints: [\n {x: 74, y: 78},\n {x: 24, y: 28},\n {x: 62, y: 28},\n {x: 30, y: 62},\n ],\n },\n },\n {\n name: \"Quick sweep\",\n props: {\n children: <Surface />,\n dwellFrames: 4,\n travelFrames: 16,\n trailLength: 22,\n waypoints: [\n {x: 82, y: 72},\n {x: 26, y: 30},\n {x: 66, y: 30},\n {x: 26, y: 64},\n {x: 74, y: 60},\n ],\n },\n },\n {\n name: \"Tinted\",\n props: {\n color: \"#7C3AED\",\n trailLength: 20,\n waypoints: [\n {x: 20, y: 70},\n {x: 50, y: 30},\n {x: 80, y: 70},\n ],\n },\n },\n ],\n});\n",
|
|
4224
|
+
"target": "videos/components/pointer-trail/pointer-trail.preview.tsx"
|
|
4225
|
+
}
|
|
4226
|
+
],
|
|
4227
|
+
"meta": {
|
|
4228
|
+
"kind": "component",
|
|
4229
|
+
"family": "Interface",
|
|
4230
|
+
"namespaced": "@odori/pointer-trail",
|
|
4231
|
+
"contract": {
|
|
4232
|
+
"aspectRatios": [
|
|
4233
|
+
"16:9",
|
|
4234
|
+
"9:16",
|
|
4235
|
+
"1:1"
|
|
4236
|
+
],
|
|
4237
|
+
"recommendedDurationInFrames": 150,
|
|
4238
|
+
"minimumDurationInFrames": 50,
|
|
4239
|
+
"entranceFrames": 0,
|
|
4240
|
+
"exitFrames": 0,
|
|
4241
|
+
"contentLimits": {
|
|
4242
|
+
"waypoints": 8
|
|
4243
|
+
},
|
|
4244
|
+
"reducedMotion": "the pointer rests at its final waypoint with no trail",
|
|
4245
|
+
"requires": {
|
|
4246
|
+
"fonts": [],
|
|
4247
|
+
"audio": []
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
},
|
|
3266
4252
|
{
|
|
3267
4253
|
"name": "pop",
|
|
3268
4254
|
"description": "A four frame bubble for something appearing.",
|
|
@@ -3364,7 +4350,7 @@
|
|
|
3364
4350
|
},
|
|
3365
4351
|
{
|
|
3366
4352
|
"path": "components/push-through/push-through.preview.tsx",
|
|
3367
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {PushThrough} from \"./push-through\";\n\nconst Panel = ({label}: {label: string}) => (\n <div\n style={{\n alignItems: \"center\",\n background:
|
|
4353
|
+
"content": "import {useBrand} from \"odori\";\nimport {defineComponentPreview} from \"odori/preview\";\nimport {PushThrough} from \"./push-through\";\n\nconst Panel = ({label}: {label: string}) => {\n const brand = useBrand();\n return (\n <div\n style={{\n alignItems: \"center\",\n background: brand.colors.background,\n color: brand.colors.foreground,\n display: \"flex\",\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 88,\n inset: 0,\n justifyContent: \"center\",\n letterSpacing: \"-0.04em\",\n position: \"absolute\",\n }}\n >\n {label}\n </div>\n );\n};\n\nexport default defineComponentPreview({\n title: \"Push through\",\n category: \"Motion\",\n description: \"A scene arriving by pushing the last one out of frame.\",\n component: PushThrough,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n direction: {type: \"select\", defaultValue: \"up\", options: [\"up\", \"down\", \"left\", \"right\"]},\n recede: {type: \"number\", defaultValue: 0.94, min: 0.8, max: 1, step: 0.01},\n },\n examples: [\n {name: \"Default\", props: {children: <Panel label=\"Arriving\" />}},\n {name: \"Sideways\", props: {direction: \"left\", children: <Panel label=\"Arriving\" />}},\n ],\n});\n",
|
|
3368
4354
|
"target": "videos/components/push-through/push-through.preview.tsx"
|
|
3369
4355
|
}
|
|
3370
4356
|
],
|
|
@@ -3391,6 +4377,49 @@
|
|
|
3391
4377
|
}
|
|
3392
4378
|
}
|
|
3393
4379
|
},
|
|
4380
|
+
{
|
|
4381
|
+
"name": "resolve-text",
|
|
4382
|
+
"description": "Glyphs flicker through seeded noise and lock in left to right.",
|
|
4383
|
+
"registryDependencies": [],
|
|
4384
|
+
"files": [
|
|
4385
|
+
{
|
|
4386
|
+
"path": "components/resolve-text/resolve-text.tsx",
|
|
4387
|
+
"content": "import {Fill, Easing, interpolate, random, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type ResolveTextProps = {\n text: string;\n detail?: string;\n /** Frames between one glyph locking and the next. */\n stagger?: number;\n /** How long a glyph flickers through noise before it locks. */\n settleFrames?: number;\n /** Characters the flicker draws from. Defaults to a dev-tool spread. */\n alphabet?: string;\n};\n\nconst DEFAULT_ALPHABET = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789#$%&*+<>/\";\n\n/**\n * Glyphs resolve out of seeded noise, locking in left to right. The scramble\n * is a pure function of the glyph index and a quantized frame step, so a\n * scrubbed preview and a parallel render flicker through the same wrong\n * characters. The mono face keeps every slot the same width, so the line\n * never shifts while it is still lying.\n */\nexport const ResolveText = ({\n text,\n detail,\n stagger = 3,\n settleFrames = 16,\n alphabet = DEFAULT_ALPHABET,\n}: ResolveTextProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const glyphs = [...text];\n const settled = 6 + glyphs.length * stagger + settleFrames;\n\n return (\n <Fill\n style={{\n alignItems: \"center\",\n gap: 30 * scale,\n justifyContent: \"center\",\n padding: `${120 * scale}px ${140 * scale}px`,\n }}\n >\n <div style={{display: \"flex\", fontFamily: brand.typography.mono, fontSize: 104 * scale, letterSpacing: \"-0.02em\"}}>\n {glyphs.map((glyph, index) => {\n // Seeded jitter keeps the lock order left-to-right in spirit\n // without reading as a metronome.\n const jitter = Math.floor(random([\"resolve-jitter\", index]) * stagger);\n const start = 4 + index * stagger + jitter;\n const lock = start + settleFrames;\n const locked = frame >= lock;\n // A new wrong character every third frame: fast enough to read as\n // noise, slow enough that each one is legible.\n const step = Math.floor(frame / 3);\n const noise = alphabet[Math.floor(random([\"resolve\", index, step]) * alphabet.length)] ?? glyph;\n const shown = glyph === \" \" || locked ? glyph : noise;\n return (\n <span\n key={`${glyph}-${index}`}\n style={{\n color: locked ? brand.colors.foreground : brand.colors.muted,\n opacity: frame < start ? 0 : locked ? 1 : 0.85,\n whiteSpace: \"pre\",\n }}\n >\n {shown}\n </span>\n );\n })}\n </div>\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 30 * scale,\n opacity: interpolate(frame, [settled, settled + 18], [0, 1], {easing: Easing.standard}),\n }}\n >\n {detail}\n </div>\n ) : null}\n </Fill>\n );\n};\n",
|
|
4388
|
+
"target": "videos/components/resolve-text/resolve-text.tsx"
|
|
4389
|
+
},
|
|
4390
|
+
{
|
|
4391
|
+
"path": "components/resolve-text/resolve-text.preview.tsx",
|
|
4392
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ResolveText} from \"./resolve-text\";\n\nexport default defineComponentPreview({\n title: \"Resolve text\",\n category: \"Typography/Titles\",\n description: \"Glyphs flicker through seeded noise and lock in left to right.\",\n component: ResolveText,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"deploy --prod\", maxLength: 24},\n detail: {type: \"text\", defaultValue: \"Zero-config, every time.\"},\n stagger: {type: \"number\", defaultValue: 3, min: 1, max: 8},\n settleFrames: {type: \"number\", defaultValue: 16, min: 4, max: 40},\n },\n examples: [\n {name: \"Default\", props: {text: \"deploy --prod\", detail: \"Zero-config, every time.\"}},\n {name: \"Slow burn\", props: {text: \"v2.0.0\", stagger: 6, settleFrames: 28}},\n {name: \"Binary\", props: {text: \"SHIPPED\", alphabet: \"01\"}},\n ],\n});\n",
|
|
4393
|
+
"target": "videos/components/resolve-text/resolve-text.preview.tsx"
|
|
4394
|
+
}
|
|
4395
|
+
],
|
|
4396
|
+
"meta": {
|
|
4397
|
+
"kind": "component",
|
|
4398
|
+
"family": "Typography",
|
|
4399
|
+
"namespaced": "@odori/resolve-text",
|
|
4400
|
+
"contract": {
|
|
4401
|
+
"aspectRatios": [
|
|
4402
|
+
"16:9",
|
|
4403
|
+
"9:16",
|
|
4404
|
+
"1:1"
|
|
4405
|
+
],
|
|
4406
|
+
"recommendedDurationInFrames": 150,
|
|
4407
|
+
"minimumDurationInFrames": 90,
|
|
4408
|
+
"entranceFrames": 80,
|
|
4409
|
+
"exitFrames": 0,
|
|
4410
|
+
"contentLimits": {
|
|
4411
|
+
"text": 20
|
|
4412
|
+
},
|
|
4413
|
+
"reducedMotion": "glyphs appear already resolved, without the flicker",
|
|
4414
|
+
"requires": {
|
|
4415
|
+
"fonts": [
|
|
4416
|
+
"mono"
|
|
4417
|
+
],
|
|
4418
|
+
"audio": []
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
},
|
|
3394
4423
|
{
|
|
3395
4424
|
"name": "review-card",
|
|
3396
4425
|
"description": "Who approved, whether the checks passed, and the one button that lands the change.",
|
|
@@ -3398,7 +4427,7 @@
|
|
|
3398
4427
|
"files": [
|
|
3399
4428
|
{
|
|
3400
4429
|
"path": "components/review-card/review-card.tsx",
|
|
3401
|
-
"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:
|
|
4430
|
+
"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: brand.colors.foreground,\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: brand.colors.foreground,\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",
|
|
3402
4431
|
"target": "videos/components/review-card/review-card.tsx"
|
|
3403
4432
|
},
|
|
3404
4433
|
{
|
|
@@ -3510,13 +4539,94 @@
|
|
|
3510
4539
|
"entranceFrames": 18,
|
|
3511
4540
|
"exitFrames": 0,
|
|
3512
4541
|
"contentLimits": {},
|
|
3513
|
-
"reducedMotion": "guides appear without fading",
|
|
4542
|
+
"reducedMotion": "guides appear without fading",
|
|
4543
|
+
"requires": {
|
|
4544
|
+
"fonts": [
|
|
4545
|
+
"sans",
|
|
4546
|
+
"mono"
|
|
4547
|
+
],
|
|
4548
|
+
"audio": []
|
|
4549
|
+
}
|
|
4550
|
+
}
|
|
4551
|
+
}
|
|
4552
|
+
},
|
|
4553
|
+
{
|
|
4554
|
+
"name": "satin-fold",
|
|
4555
|
+
"description": "Stacked filled sine ribbons flowing at seeded, disagreeing paces across the lower frame.",
|
|
4556
|
+
"registryDependencies": [],
|
|
4557
|
+
"files": [
|
|
4558
|
+
{
|
|
4559
|
+
"path": "components/satin-fold/satin-fold.tsx",
|
|
4560
|
+
"content": "import {useId} from \"react\";\nimport {Fill, random, randomBetween, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\n/**\n * Stacked ribbons of fabric across the lower frame, each a filled sine wave\n * flowing at its own pace.\n *\n * The silk read comes from disagreement: every ribbon takes its wavelength,\n * amplitude, and phase from its own seed, so the crests slide past each other\n * instead of marching. Each ribbon fills down to the bottom edge and fades as\n * it goes, and the stack is painted back to front, so the overlaps deepen the\n * colour the way layered cloth does rather than flattening into one shape.\n *\n * Amplitude is a design length: the same swell in a vertical or square cut.\n */\nexport type SatinFoldProps = {\n /** How many ribbons stack, 3 to 5. */\n ribbons?: number;\n /** Height of the largest swell, in design pixels. */\n amplitude?: number;\n /** How fast the ribbons flow. */\n speed?: number;\n /** One colour per ribbon, cycled if short. Defaults to the brand. */\n colors?: string[];\n};\n\nexport const SatinFold = ({ribbons = 4, amplitude = 90, speed = 1, colors}: SatinFoldProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {fps, width, height} = useVideo();\n // useId keeps two stacks on one canvas from sharing gradients; the colons\n // it decorates with are stripped because url(#...) cannot carry them.\n const gradientId = useId().replace(/:/g, \"silk\");\n const palette = colors?.length ? colors : [brand.colors.accent, brand.colors.muted, brand.colors.surface];\n const count = Math.max(3, Math.min(5, Math.round(ribbons)));\n const t = (frame / fps) * speed;\n\n return (\n <Fill style={{background: brand.colors.background}}>\n <svg aria-hidden height={height} width={width} style={{display: \"block\"}}>\n <defs>\n {Array.from({length: count}, (_, index) => (\n <linearGradient key={index} id={`${gradientId}-${index}`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n <stop offset=\"0%\" stopColor={palette[index % palette.length]} stopOpacity={0.35} />\n <stop offset=\"100%\" stopColor={palette[index % palette.length]} stopOpacity={0} />\n </linearGradient>\n ))}\n </defs>\n {Array.from({length: count}, (_, index) => {\n const along = index / (count - 1);\n const baseline = height * (0.42 + along * 0.4);\n const swell = amplitude * randomBetween(`ribbon-${index}-amp`, 0.6, 1) * scale;\n const wavelength = width / randomBetween(`ribbon-${index}-waves`, 1.4, 2.6);\n const phase = random(`ribbon-${index}-phase`) * Math.PI * 2;\n const rate = randomBetween(`ribbon-${index}-rate`, 0.25, 0.5) * Math.PI * 2;\n\n // Sampled every ~2% of the width, which is below what the eye can\n // pick out of a sine once it is filled and layered.\n const steps = 48;\n let path = `M 0 ${height}`;\n for (let step = 0; step <= steps; step += 1) {\n const x = (step / steps) * width;\n const y =\n baseline +\n Math.sin((x / wavelength) * Math.PI * 2 + phase + t * rate) * swell +\n Math.sin((x / wavelength) * Math.PI * 4.6 + phase * 2 - t * rate * 0.6) * swell * 0.25;\n path += ` L ${x.toFixed(1)} ${y.toFixed(1)}`;\n }\n path += ` L ${width} ${height} Z`;\n\n return <path key={index} d={path} fill={`url(#${gradientId}-${index})`} />;\n })}\n </svg>\n </Fill>\n );\n};\n",
|
|
4561
|
+
"target": "videos/components/satin-fold/satin-fold.tsx"
|
|
4562
|
+
},
|
|
4563
|
+
{
|
|
4564
|
+
"path": "components/satin-fold/satin-fold.preview.tsx",
|
|
4565
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SatinFold} from \"./satin-fold\";\n\nexport default defineComponentPreview({\n title: \"Satin fold\",\n category: \"Backgrounds\",\n description: \"Stacked filled sine ribbons flowing at seeded, disagreeing paces across the lower frame.\",\n component: SatinFold,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n ribbons: {type: \"number\", defaultValue: 4, min: 3, max: 5, step: 1},\n amplitude: {type: \"number\", defaultValue: 90, min: 20, max: 220, step: 10},\n speed: {type: \"number\", defaultValue: 1, min: 0, max: 3, step: 0.1},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Ocean\", props: {colors: [\"#1b6d8f\", \"#2aa198\", \"#123c55\"], amplitude: 120}},\n {name: \"Calm\", props: {ribbons: 3, amplitude: 50, speed: 0.5}},\n ],\n});\n",
|
|
4566
|
+
"target": "videos/components/satin-fold/satin-fold.preview.tsx"
|
|
4567
|
+
}
|
|
4568
|
+
],
|
|
4569
|
+
"meta": {
|
|
4570
|
+
"kind": "component",
|
|
4571
|
+
"family": "Backgrounds",
|
|
4572
|
+
"namespaced": "@odori/satin-fold",
|
|
4573
|
+
"contract": {
|
|
4574
|
+
"aspectRatios": [
|
|
4575
|
+
"16:9",
|
|
4576
|
+
"9:16",
|
|
4577
|
+
"1:1"
|
|
4578
|
+
],
|
|
4579
|
+
"recommendedDurationInFrames": 180,
|
|
4580
|
+
"minimumDurationInFrames": 30,
|
|
4581
|
+
"entranceFrames": 0,
|
|
4582
|
+
"exitFrames": 0,
|
|
4583
|
+
"contentLimits": {},
|
|
4584
|
+
"reducedMotion": "set speed to 0 and the ribbons hold one still curve",
|
|
4585
|
+
"requires": {
|
|
4586
|
+
"fonts": [],
|
|
4587
|
+
"audio": []
|
|
4588
|
+
}
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
},
|
|
4592
|
+
{
|
|
4593
|
+
"name": "scan-glow",
|
|
4594
|
+
"description": "Fine scanlines with one soft refresh band sweeping down on a slow cycle, under a faint vignette.",
|
|
4595
|
+
"registryDependencies": [],
|
|
4596
|
+
"files": [
|
|
4597
|
+
{
|
|
4598
|
+
"path": "components/scan-glow/scan-glow.tsx",
|
|
4599
|
+
"content": "import {Fill, useBrand, useDesignScale, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * A scanline ground: fine horizontal lines, one soft refresh band sweeping\n * down on a slow cycle, and a faint vignette to hold the corners in.\n *\n * The CRT feel without the terminal. The lines are a raised cosine sharpened\n * so the bright ridge is thin and the gap is wide — texture, not stripes —\n * and the sweep brightens the lines it passes rather than painting over them,\n * which is what a real refresh looked like. The band position is a pure\n * function of `seconds`, so the cycle is exact and every worker agrees on it.\n *\n * Line pitch is a design length, scaled outside the shader, so the texture\n * stays the same apparent fineness in a vertical or square cut.\n */\nexport type ScanGlowProps = {\n /** Glow colour. Defaults to the brand accent. */\n color?: string;\n /** Distance between scanlines, in design pixels. */\n lines?: number;\n /** Seconds per full sweep. 0 removes the sweep and holds the texture still. */\n sweepSeconds?: number;\n /** How bright the lines and sweep sit over the ground. */\n intensity?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst glow = defineShaderEffect({\n name: \"scan-glow\",\n uniforms: {\n pitch: numberUniform(4),\n sweepSeconds: numberUniform(5),\n intensity: numberUniform(0.6),\n base: vec3Uniform([0.04, 0.05, 0.09]),\n ink: vec3Uniform([0.45, 0.55, 0.85]),\n },\n fragmentShader: `\nuniform float pitch;\nuniform float sweepSeconds;\nuniform float intensity;\nuniform vec3 base;\nuniform vec3 ink;\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n\n /* A raised cosine per pitch, cubed so the ridge is thin against a wide\n gap. This is texture at reading distance, not a striped wall. */\n float line = 0.5 + 0.5 * cos(gl_FragCoord.y * 6.2831853 / pitch);\n line = line * line * line;\n\n /* The refresh band falls from top to bottom once per cycle. A cycle of 0\n seconds means no sweep at all, which is the reduced-motion story. */\n float band = 0.0;\n if (sweepSeconds > 0.0) {\n float pos = 1.0 - fract(seconds / sweepSeconds);\n band = smoothstep(0.16, 0.0, abs(uv.y - pos));\n }\n\n /* A faint vignette, quadratic from centre, so the ground has a middle. */\n vec2 c = uv - 0.5;\n c.x *= resolution.x / resolution.y;\n float vignette = 1.0 - dot(c, c) * 0.55;\n\n /* The sweep mostly brightens the lines it passes, with a little ambient\n lift of its own — a refresh, not a searchlight. */\n float lift = line * 0.22 + band * (0.1 + line * 0.5);\n odoriColour = vec4((base + ink * lift * intensity) * vignette, 1.0);\n}`,\n});\n\nexport const ScanGlow = ({color, lines = 4, sweepSeconds = 5, intensity = 0.6}: ScanGlowProps) => {\n const brand = useBrand();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n const ink = color ?? brand.colors.accent;\n\n return (\n <EffectSurface\n effects={[\n glow({\n pitch: Math.max(2, lines * scale),\n sweepSeconds,\n intensity,\n base: hexToRgb(brand.colors.background),\n ink: hexToRgb(ink),\n }),\n ]}\n >\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n",
|
|
4600
|
+
"target": "videos/components/scan-glow/scan-glow.tsx"
|
|
4601
|
+
},
|
|
4602
|
+
{
|
|
4603
|
+
"path": "components/scan-glow/scan-glow.preview.tsx",
|
|
4604
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ScanGlow} from \"./scan-glow\";\n\nexport default defineComponentPreview({\n title: \"Scan glow\",\n category: \"Backgrounds\",\n description: \"Fine scanlines with one soft refresh band sweeping down on a slow cycle, under a faint vignette.\",\n component: ScanGlow,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n lines: {type: \"number\", defaultValue: 4, min: 2, max: 16, step: 1},\n sweepSeconds: {type: \"number\", defaultValue: 5, min: 0, max: 12, step: 0.5},\n intensity: {type: \"number\", defaultValue: 0.6, min: 0, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Phosphor\", props: {color: \"#4ade80\", lines: 3, intensity: 0.7}},\n {name: \"Still texture\", props: {sweepSeconds: 0, intensity: 0.45}},\n ],\n});\n",
|
|
4605
|
+
"target": "videos/components/scan-glow/scan-glow.preview.tsx"
|
|
4606
|
+
}
|
|
4607
|
+
],
|
|
4608
|
+
"meta": {
|
|
4609
|
+
"kind": "component",
|
|
4610
|
+
"family": "Backgrounds",
|
|
4611
|
+
"namespaced": "@odori/scan-glow",
|
|
4612
|
+
"contract": {
|
|
4613
|
+
"aspectRatios": [
|
|
4614
|
+
"16:9",
|
|
4615
|
+
"9:16",
|
|
4616
|
+
"1:1"
|
|
4617
|
+
],
|
|
4618
|
+
"recommendedDurationInFrames": 180,
|
|
4619
|
+
"minimumDurationInFrames": 30,
|
|
4620
|
+
"entranceFrames": 0,
|
|
4621
|
+
"exitFrames": 0,
|
|
4622
|
+
"contentLimits": {},
|
|
4623
|
+
"reducedMotion": "set sweepSeconds to 0 and only the still line texture and vignette remain",
|
|
3514
4624
|
"requires": {
|
|
3515
|
-
"fonts": [
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
4625
|
+
"fonts": [],
|
|
4626
|
+
"audio": [],
|
|
4627
|
+
"graphics": [
|
|
4628
|
+
"webgl2"
|
|
4629
|
+
]
|
|
3520
4630
|
}
|
|
3521
4631
|
}
|
|
3522
4632
|
}
|
|
@@ -3571,7 +4681,7 @@
|
|
|
3571
4681
|
"files": [
|
|
3572
4682
|
{
|
|
3573
4683
|
"path": "components/settings-toggles/settings-toggles.tsx",
|
|
3574
|
-
"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:
|
|
4684
|
+
"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: brand.colors.background, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n background: brand.colors.background,\n border: `${px(1)}px solid ${brand.colors.border}`,\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 ${brand.colors.border}`,\n color: brand.colors.foreground,\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: brand.colors.foreground, fontSize: px(23)}}>{row.label}</div>\n {row.hint ? (\n <div\n style={{\n color: brand.colors.muted,\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",
|
|
3575
4685
|
"target": "videos/components/settings-toggles/settings-toggles.tsx"
|
|
3576
4686
|
},
|
|
3577
4687
|
{
|
|
@@ -3606,6 +4716,48 @@
|
|
|
3606
4716
|
}
|
|
3607
4717
|
}
|
|
3608
4718
|
},
|
|
4719
|
+
{
|
|
4720
|
+
"name": "shape-lattice",
|
|
4721
|
+
"description": "Circles, squares and triangles on a strict pitch, each turning a beat after the cell before it.",
|
|
4722
|
+
"registryDependencies": [],
|
|
4723
|
+
"files": [
|
|
4724
|
+
{
|
|
4725
|
+
"path": "components/shape-lattice/shape-lattice.tsx",
|
|
4726
|
+
"content": "import {Fill, useBrand, useDesignScale, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * A lattice of marks, each turning on its own beat.\n *\n * The grid is the composition and the cell is the event: circles, squares and\n * triangles laid on a strict pitch, some filled and some drawn as outlines,\n * every one of them rotating and breathing a fraction later than the cell\n * before it. That lag is the whole effect — rotate them together and the wall\n * reads as one object shaking, rotate them in sequence and it reads as a\n * surface with a wave moving through it.\n *\n * Which mark lands in which cell is hashed from the cell's own coordinates, so\n * the arrangement never changes between renders and never has to be stored.\n */\nexport type ShapeLatticeProps = {\n /** Mark and ground. Defaults to the brand's foreground on its background. */\n colors?: [string, string];\n /** Marks across the frame. */\n columns?: number;\n /** How fast the wave crosses the lattice. */\n speed?: number;\n /** Share of cells drawn as outlines rather than filled, 0 to 1. */\n outlines?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst lattice = defineShaderEffect({\n name: \"shape-lattice\",\n uniforms: {\n columns: numberUniform(14),\n speed: numberUniform(1),\n outlines: numberUniform(0.45),\n ink: vec3Uniform([1, 1, 1]),\n ground: vec3Uniform([0, 0, 0]),\n },\n fragmentShader: `\nuniform float columns;\nuniform float speed;\nuniform float outlines;\nuniform vec3 ink;\nuniform vec3 ground;\n\n/* hash() comes from the prelude: integer PCG, exact on every backend. The\n trigonometric idiom everyone reaches for drifts between graphics backends,\n and a lattice whose marks change identity between two export workers is not\n a lattice. */\n\nfloat sdCircle(vec2 p, float r) { return length(p) - r; }\n\nfloat sdBox(vec2 p, float b) {\n vec2 d = abs(p) - vec2(b);\n return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);\n}\n\nfloat sdTriangle(vec2 p, float r) {\n const float k = 1.7320508;\n p.x = abs(p.x) - r;\n p.y = p.y + r / k;\n if (p.x + k * p.y > 0.0) p = vec2(p.x - k * p.y, -k * p.x - p.y) / 2.0;\n p.x -= clamp(p.x, -2.0 * r, 0.0);\n return -length(p) * sign(p.y);\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n float aspect = resolution.x / resolution.y;\n vec2 p = vec2(uv.x * aspect, uv.y);\n\n float cols = max(2.0, columns);\n float cell = aspect / cols;\n vec2 id = floor(p / cell);\n vec2 local = fract(p / cell) - 0.5;\n\n /* The lag that makes it a wave rather than a shudder: a cell's phase is its\n distance along the diagonal, so the turn crosses the lattice. */\n float t = seconds * speed;\n float phase = (id.x + id.y) * 0.38;\n float angle = sin(t + phase) * 1.25;\n float c = cos(angle);\n float s = sin(angle);\n vec2 q = mat2(c, -s, s, c) * local;\n\n float size = 0.26 + 0.09 * sin(t * 0.8 + phase);\n float pick = hash(id);\n float d = pick < 0.34 ? sdCircle(q, size) : pick < 0.67 ? sdBox(q, size) : sdTriangle(q, size * 1.15);\n\n /* One pixel's worth of edge, whatever the frame's size, so the marks are\n the same softness in a vertical cut as in a wide one. */\n float edge = 1.4 / (resolution.y * cell);\n float filled = 1.0 - smoothstep(-edge, edge, d);\n float ring = 1.0 - smoothstep(0.0, edge * 2.2, abs(d + 0.028));\n float mask = mix(filled, ring, step(hash(id + 7.31), outlines));\n\n odoriColour = vec4(mix(ground, ink, mask * 0.92), 1.0);\n}`,\n});\n\nexport const ShapeLattice = ({colors, columns = 14, speed = 1, outlines = 0.45}: ShapeLatticeProps) => {\n const brand = useBrand();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n const [ink, ground] = colors ?? [brand.colors.foreground, brand.colors.background];\n\n return (\n <EffectSurface\n effects={[\n lattice({\n // Cells are a count, not a length, so the lattice keeps its pitch in\n // any aspect rather than growing with the frame.\n columns: Math.max(2, Math.round(columns * Math.min(1.35, Math.max(0.75, scale)))),\n speed,\n outlines,\n ink: hexToRgb(ink),\n ground: hexToRgb(ground),\n }),\n ]}\n >\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n",
|
|
4727
|
+
"target": "videos/components/shape-lattice/shape-lattice.tsx"
|
|
4728
|
+
},
|
|
4729
|
+
{
|
|
4730
|
+
"path": "components/shape-lattice/shape-lattice.preview.tsx",
|
|
4731
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {ShapeLattice} from \"./shape-lattice\";\n\nexport default defineComponentPreview({\n title: \"Shape lattice\",\n category: \"Backgrounds\",\n description: \"Circles, squares and triangles on a strict pitch, each turning a beat after the cell before it.\",\n component: ShapeLattice,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n columns: {type: \"number\", defaultValue: 14, min: 4, max: 40, step: 1},\n speed: {type: \"number\", defaultValue: 1, min: 0, max: 3, step: 0.1},\n outlines: {type: \"number\", defaultValue: 0.45, min: 0, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Fine\", props: {columns: 28, outlines: 0.7, speed: 0.6}},\n {name: \"Bold\", props: {columns: 8, outlines: 0.1, speed: 1.4}},\n ],\n});\n",
|
|
4732
|
+
"target": "videos/components/shape-lattice/shape-lattice.preview.tsx"
|
|
4733
|
+
}
|
|
4734
|
+
],
|
|
4735
|
+
"meta": {
|
|
4736
|
+
"kind": "component",
|
|
4737
|
+
"family": "Backgrounds",
|
|
4738
|
+
"namespaced": "@odori/shape-lattice",
|
|
4739
|
+
"contract": {
|
|
4740
|
+
"aspectRatios": [
|
|
4741
|
+
"16:9",
|
|
4742
|
+
"9:16",
|
|
4743
|
+
"1:1"
|
|
4744
|
+
],
|
|
4745
|
+
"recommendedDurationInFrames": 240,
|
|
4746
|
+
"minimumDurationInFrames": 60,
|
|
4747
|
+
"entranceFrames": 0,
|
|
4748
|
+
"exitFrames": 0,
|
|
4749
|
+
"contentLimits": {},
|
|
4750
|
+
"reducedMotion": "set speed to 0 and every mark holds one angle",
|
|
4751
|
+
"requires": {
|
|
4752
|
+
"fonts": [],
|
|
4753
|
+
"audio": [],
|
|
4754
|
+
"graphics": [
|
|
4755
|
+
"webgl2"
|
|
4756
|
+
]
|
|
4757
|
+
}
|
|
4758
|
+
}
|
|
4759
|
+
}
|
|
4760
|
+
},
|
|
3609
4761
|
{
|
|
3610
4762
|
"name": "shared-axis",
|
|
3611
4763
|
"description": "Related scenes travelling on one spatial axis.",
|
|
@@ -3618,7 +4770,7 @@
|
|
|
3618
4770
|
},
|
|
3619
4771
|
{
|
|
3620
4772
|
"path": "components/shared-axis/shared-axis.preview.tsx",
|
|
3621
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SharedAxis} from \"./shared-axis\";\n\nconst Panel = () => (\n <div\n style={{\n alignItems: \"center\",\n background:
|
|
4773
|
+
"content": "import {useBrand} from \"odori\";\nimport {defineComponentPreview} from \"odori/preview\";\nimport {SharedAxis} from \"./shared-axis\";\n\nconst Panel = () => {\n const brand = useBrand();\n return (\n <div\n style={{\n alignItems: \"center\",\n background: brand.colors.background,\n color: brand.colors.foreground,\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: brand.colors.muted, fontSize: 32}}>the same thing, one level in</span>\n </div>\n );\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",
|
|
3622
4774
|
"target": "videos/components/shared-axis/shared-axis.preview.tsx"
|
|
3623
4775
|
}
|
|
3624
4776
|
],
|
|
@@ -3645,6 +4797,49 @@
|
|
|
3645
4797
|
}
|
|
3646
4798
|
}
|
|
3647
4799
|
},
|
|
4800
|
+
{
|
|
4801
|
+
"name": "sheen-text",
|
|
4802
|
+
"description": "A single accent sweep crosses an already-set line.",
|
|
4803
|
+
"registryDependencies": [],
|
|
4804
|
+
"files": [
|
|
4805
|
+
{
|
|
4806
|
+
"path": "components/sheen-text/sheen-text.tsx",
|
|
4807
|
+
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type SheenTextProps = {\n text: string;\n /** Frame the sweep begins. */\n at?: number;\n /** Frames the sweep takes to cross the line. */\n sweepFrames?: number;\n /** Sheen color. Defaults to the brand accent. */\n color?: string;\n};\n\n/**\n * The line is set immediately; one accent-colored highlight travels across it\n * and never comes back. The sheen is a gradient clipped to the glyphs whose\n * position is derived from the frame, so it crosses exactly once and holds —\n * a looping shimmer reads as a UI affordance, not an edit.\n */\nexport const SheenText = ({text, at = 20, sweepFrames = 30, color}: SheenTextProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const sheen = color ?? brand.colors.accent;\n const fg = brand.colors.foreground;\n // The band runs past both edges so the line starts and ends fully settled.\n const position = interpolate(frame, [at, at + sweepFrames], [-25, 125], {easing: Easing.standard});\n\n return (\n <Fill\n style={{\n alignItems: \"center\",\n justifyContent: \"center\",\n padding: `${120 * scale}px ${140 * scale}px`,\n }}\n >\n <div\n style={{\n backgroundImage: `linear-gradient(100deg, ${fg} ${position - 14}%, ${sheen} ${position}%, ${fg} ${position + 14}%)`,\n WebkitBackgroundClip: \"text\",\n backgroundClip: \"text\",\n WebkitTextFillColor: \"transparent\",\n color: \"transparent\",\n fontSize: 128 * scale,\n fontWeight: 600,\n letterSpacing: \"-0.045em\",\n lineHeight: 1.04,\n maxWidth: 1560 * scale,\n textAlign: \"center\",\n }}\n >\n {text}\n </div>\n </Fill>\n );\n};\n",
|
|
4808
|
+
"target": "videos/components/sheen-text/sheen-text.tsx"
|
|
4809
|
+
},
|
|
4810
|
+
{
|
|
4811
|
+
"path": "components/sheen-text/sheen-text.preview.tsx",
|
|
4812
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SheenText} from \"./sheen-text\";\n\nexport default defineComponentPreview({\n title: \"Sheen text\",\n category: \"Typography/Titles\",\n description: \"A single accent sweep crosses an already-set line.\",\n component: SheenText,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n text: {type: \"text\", defaultValue: \"Polished by default\", maxLength: 32},\n at: {type: \"number\", defaultValue: 20, min: 0, max: 90},\n sweepFrames: {type: \"number\", defaultValue: 30, min: 10, max: 90},\n },\n examples: [\n {name: \"Default\", props: {text: \"Polished by default\"}},\n {name: \"Late and slow\", props: {text: \"Wait for it\", at: 45, sweepFrames: 60}},\n ],\n});\n",
|
|
4813
|
+
"target": "videos/components/sheen-text/sheen-text.preview.tsx"
|
|
4814
|
+
}
|
|
4815
|
+
],
|
|
4816
|
+
"meta": {
|
|
4817
|
+
"kind": "component",
|
|
4818
|
+
"family": "Typography",
|
|
4819
|
+
"namespaced": "@odori/sheen-text",
|
|
4820
|
+
"contract": {
|
|
4821
|
+
"aspectRatios": [
|
|
4822
|
+
"16:9",
|
|
4823
|
+
"9:16",
|
|
4824
|
+
"1:1"
|
|
4825
|
+
],
|
|
4826
|
+
"recommendedDurationInFrames": 90,
|
|
4827
|
+
"minimumDurationInFrames": 60,
|
|
4828
|
+
"entranceFrames": 0,
|
|
4829
|
+
"exitFrames": 0,
|
|
4830
|
+
"contentLimits": {
|
|
4831
|
+
"text": 32
|
|
4832
|
+
},
|
|
4833
|
+
"reducedMotion": "the line is shown in the foreground color with no sweep",
|
|
4834
|
+
"requires": {
|
|
4835
|
+
"fonts": [
|
|
4836
|
+
"sans"
|
|
4837
|
+
],
|
|
4838
|
+
"audio": []
|
|
4839
|
+
}
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
},
|
|
3648
4843
|
{
|
|
3649
4844
|
"name": "sheet-panel",
|
|
3650
4845
|
"description": "A side panel arriving while the page behind it gives way.",
|
|
@@ -3652,7 +4847,7 @@
|
|
|
3652
4847
|
"files": [
|
|
3653
4848
|
{
|
|
3654
4849
|
"path": "components/sheet-panel/sheet-panel.tsx",
|
|
3655
|
-
"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:
|
|
4850
|
+
"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: brand.colors.background, 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: brand.colors.background,\n border: `${px(1)}px solid ${brand.colors.border}`,\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: brand.colors.foreground, fontSize: px(34), fontWeight: 500}}>Launch film</div>\n <div style={{color: brand.colors.muted, 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: brand.colors.surface, borderRadius: px(10), height: px(70)}} />\n ))}\n </div>\n </div>\n\n <span\n style={{\n background: brand.colors.background,\n inset: 0,\n opacity: open * 0.55,\n position: \"absolute\",\n }}\n />\n\n <div\n style={{\n background: brand.colors.background,\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 ${brand.colors.shadow}`,\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: brand.colors.foreground, fontSize: px(30), fontWeight: 500}}>{title}</div>\n <div style={{color: brand.colors.muted, 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: brand.colors.muted, fontSize: px(18)}}>{field.label}</div>\n <div\n style={{\n border: `${px(1)}px solid ${brand.colors.border}`,\n borderRadius: px(9),\n color: brand.colors.foreground,\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",
|
|
3656
4851
|
"target": "videos/components/sheet-panel/sheet-panel.tsx"
|
|
3657
4852
|
},
|
|
3658
4853
|
{
|
|
@@ -3696,7 +4891,7 @@
|
|
|
3696
4891
|
"files": [
|
|
3697
4892
|
{
|
|
3698
4893
|
"path": "components/skeleton-load/skeleton-load.tsx",
|
|
3699
|
-
"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:
|
|
4894
|
+
"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: brand.colors.background, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n background: brand.colors.background,\n border: `${px(1)}px solid ${brand.colors.border}`,\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 ${brand.colors.border}`,\n color: brand.colors.foreground,\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: brand.colors.foreground, fontFamily: brand.typography.mono, fontSize: px(22)}}>{row.title}</div>\n <div style={{color: brand.colors.muted, fontSize: px(19), marginTop: px(7)}}>{row.meta}</div>\n </div>\n </div>\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
|
|
3700
4895
|
"target": "videos/components/skeleton-load/skeleton-load.tsx"
|
|
3701
4896
|
},
|
|
3702
4897
|
{
|
|
@@ -3817,6 +5012,48 @@
|
|
|
3817
5012
|
}
|
|
3818
5013
|
}
|
|
3819
5014
|
},
|
|
5015
|
+
{
|
|
5016
|
+
"name": "spiral-arm",
|
|
5017
|
+
"description": "A galaxy under differential rotation: the core outruns the rim and the arms wind as it turns.",
|
|
5018
|
+
"registryDependencies": [],
|
|
5019
|
+
"files": [
|
|
5020
|
+
{
|
|
5021
|
+
"path": "components/spiral-arm/spiral-arm.tsx",
|
|
5022
|
+
"content": "import {Fill, useBrand, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * A galaxy, turning the way a real one does.\n *\n * The detail that sells it is differential rotation: the centre completes a\n * turn while the rim has barely moved, so the arms wind tighter as the shot\n * runs instead of sweeping round like a wheel. Everything else follows from\n * that — the arms are a cosine in polar coordinates, wound by the logarithm of\n * the radius, which is the shape a spiral galaxy actually has.\n *\n * Stars are hashed from their own cell rather than stored, so the same frame\n * draws the same sky twice and two export workers meeting at a chunk boundary\n * agree on every one of them.\n */\nexport type SpiralArmProps = {\n /** Core and arms. Defaults to the brand's lightest token on its darkest. */\n colors?: [string, string];\n /** How many arms wind out of the core. */\n arms?: number;\n /** How tightly they wind. Low is open, high is a whirlpool. */\n twist?: number;\n /** How fast the galaxy turns. */\n speed?: number;\n /** How much dust the arms carry, 0 to 1. */\n density?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\n/** Space is dark under any brand, so the ground is the darker token. */\nconst tones = (background: string, foreground: string): [string, string] => {\n const luminance = (hex: string): number => {\n const [r, g, b] = hexToRgb(hex);\n return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n };\n return luminance(background) <= luminance(foreground)\n ? [background, foreground]\n : [foreground, background];\n};\n\nconst galaxy = defineShaderEffect({\n name: \"spiral-arm\",\n uniforms: {\n arms: numberUniform(2),\n twist: numberUniform(4.2),\n speed: numberUniform(1),\n density: numberUniform(0.7),\n light: vec3Uniform([1, 1, 1]),\n ground: vec3Uniform([0, 0, 0]),\n },\n fragmentShader: `\nuniform float arms;\nuniform float twist;\nuniform float speed;\nuniform float density;\nuniform vec3 light;\nuniform vec3 ground;\n\n/* hash() comes from the prelude: integer PCG, exact on every backend, so the\n same frame draws the same stars wherever it is rendered. */\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n vec2 p = (uv - 0.5) * vec2(resolution.x / resolution.y, 1.0) * 2.0;\n float r = length(p);\n float a = atan(p.y, p.x);\n float t = seconds * speed;\n\n /* Differential rotation. The inner disc runs away from the rim, which is\n what winds the arms as the shot plays rather than spinning them rigidly. */\n float spin = t * (0.55 / (r + 0.32));\n float field = cos(arms * (a + spin) - log(r + 0.08) * twist);\n float arm = pow(max(field, 0.0), 2.8) * exp(-r * 1.85);\n\n vec3 colour = ground;\n\n /* The disc, then the core on top of it: a bulge bright enough to blow out\n in the middle is the difference between a galaxy and a pinwheel. */\n colour += light * arm * density * 0.85;\n colour += light * exp(-r * 5.6) * 1.15;\n\n /* Stars, denser inside the arms because that is where they form. */\n vec2 cell = floor(uv * 260.0);\n float roll = hash(cell);\n float star = step(0.9965 - arm * 0.02, roll);\n float twinkle = 0.55 + 0.45 * sin(t * 2.6 + roll * 31.0);\n colour += light * star * twinkle * (0.30 + arm * 0.8);\n\n odoriColour = vec4(colour, 1.0);\n}`,\n});\n\nexport const SpiralArm = ({colors, arms = 2, twist = 4.2, speed = 1, density = 0.7}: SpiralArmProps) => {\n const brand = useBrand();\n const {width, height} = useVideo();\n const [ground, light] = colors\n ? [colors[1], colors[0]]\n : tones(brand.colors.background, brand.colors.foreground);\n\n return (\n <EffectSurface\n effects={[\n galaxy({arms, twist, speed, density, light: hexToRgb(light), ground: hexToRgb(ground)}),\n ]}\n >\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n",
|
|
5023
|
+
"target": "videos/components/spiral-arm/spiral-arm.tsx"
|
|
5024
|
+
},
|
|
5025
|
+
{
|
|
5026
|
+
"path": "components/spiral-arm/spiral-arm.preview.tsx",
|
|
5027
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SpiralArm} from \"./spiral-arm\";\n\nexport default defineComponentPreview({\n title: \"Spiral arm\",\n category: \"Backgrounds\",\n description: \"A galaxy under differential rotation: the core outruns the rim and the arms wind as it turns.\",\n component: SpiralArm,\n canvas: {width: 1920, height: 1080, duration: \"10s\"},\n controls: {\n arms: {type: \"number\", defaultValue: 2, min: 1, max: 6, step: 1},\n twist: {type: \"number\", defaultValue: 4.2, min: 1, max: 10, step: 0.2},\n speed: {type: \"number\", defaultValue: 1, min: 0, max: 3, step: 0.1},\n density: {type: \"number\", defaultValue: 0.7, min: 0, max: 1, step: 0.05},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Whirlpool\", props: {arms: 2, twist: 8.5, density: 0.9}},\n {name: \"Open four\", props: {arms: 4, twist: 2.4, speed: 0.6}},\n ],\n});\n",
|
|
5028
|
+
"target": "videos/components/spiral-arm/spiral-arm.preview.tsx"
|
|
5029
|
+
}
|
|
5030
|
+
],
|
|
5031
|
+
"meta": {
|
|
5032
|
+
"kind": "component",
|
|
5033
|
+
"family": "Backgrounds",
|
|
5034
|
+
"namespaced": "@odori/spiral-arm",
|
|
5035
|
+
"contract": {
|
|
5036
|
+
"aspectRatios": [
|
|
5037
|
+
"16:9",
|
|
5038
|
+
"9:16",
|
|
5039
|
+
"1:1"
|
|
5040
|
+
],
|
|
5041
|
+
"recommendedDurationInFrames": 300,
|
|
5042
|
+
"minimumDurationInFrames": 60,
|
|
5043
|
+
"entranceFrames": 0,
|
|
5044
|
+
"exitFrames": 0,
|
|
5045
|
+
"contentLimits": {},
|
|
5046
|
+
"reducedMotion": "set speed to 0 and the galaxy holds still",
|
|
5047
|
+
"requires": {
|
|
5048
|
+
"fonts": [],
|
|
5049
|
+
"audio": [],
|
|
5050
|
+
"graphics": [
|
|
5051
|
+
"webgl2"
|
|
5052
|
+
]
|
|
5053
|
+
}
|
|
5054
|
+
}
|
|
5055
|
+
}
|
|
5056
|
+
},
|
|
3820
5057
|
{
|
|
3821
5058
|
"name": "split-layout",
|
|
3822
5059
|
"description": "Responsive two-up composition for proof and explanation.",
|
|
@@ -3873,7 +5110,7 @@
|
|
|
3873
5110
|
},
|
|
3874
5111
|
{
|
|
3875
5112
|
"path": "components/split-wipe/split-wipe.preview.tsx",
|
|
3876
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SplitWipe} from \"./split-wipe\";\n\nconst Behind = () => (\n <div\n style={{\n alignItems: \"center\",\n background:
|
|
5113
|
+
"content": "import {useBrand} from \"odori\";\nimport {defineComponentPreview} from \"odori/preview\";\nimport {SplitWipe} from \"./split-wipe\";\n\nconst Behind = () => {\n const brand = useBrand();\n return (\n <div\n style={{\n alignItems: \"center\",\n background: brand.colors.background,\n color: brand.colors.foreground,\n display: \"flex\",\n fontFamily: \"ui-sans-serif, system-ui\",\n fontSize: 96,\n inset: 0,\n justifyContent: \"center\",\n letterSpacing: \"-0.04em\",\n position: \"absolute\",\n }}\n >\n Ship the story.\n </div>\n );\n};\n\nexport default defineComponentPreview({\n title: \"Split wipe\",\n category: \"Motion\",\n description: \"Two panels parting to reveal the scene behind them.\",\n component: SplitWipe,\n canvas: {width: 1920, height: 1080, duration: \"3s\"},\n controls: {\n direction: {type: \"select\", defaultValue: \"horizontal\", options: [\"horizontal\", \"vertical\"]},\n at: {type: \"number\", defaultValue: 0, min: 0, max: 60},\n durationInFrames: {type: \"number\", defaultValue: 26, min: 8, max: 90},\n seam: {type: \"boolean\", defaultValue: true},\n },\n examples: [\n {name: \"Default\", props: {children: <Behind />}},\n {name: \"Vertical\", props: {direction: \"vertical\", children: <Behind />}},\n {name: \"Slow\", props: {durationInFrames: 60, children: <Behind />}},\n ],\n});\n",
|
|
3877
5114
|
"target": "videos/components/split-wipe/split-wipe.preview.tsx"
|
|
3878
5115
|
}
|
|
3879
5116
|
],
|
|
@@ -3900,6 +5137,51 @@
|
|
|
3900
5137
|
}
|
|
3901
5138
|
}
|
|
3902
5139
|
},
|
|
5140
|
+
{
|
|
5141
|
+
"name": "spotlight-card",
|
|
5142
|
+
"description": "A row of cards that a soft light visits in turn, dwelling and travelling on the frame clock.",
|
|
5143
|
+
"registryDependencies": [],
|
|
5144
|
+
"files": [
|
|
5145
|
+
{
|
|
5146
|
+
"path": "components/spotlight-card/spotlight-card.tsx",
|
|
5147
|
+
"content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from \"odori\";\n\nexport type SpotlightItem = {title: string; body?: string};\n\nexport type SpotlightCardProps = {\n items?: SpotlightItem[];\n /** Frames the light rests on each card. */\n dwellFrames?: number;\n /** Frames the light takes to reach the next card. */\n travelFrames?: number;\n /** Cards per row. Defaults to everything on one row. */\n columns?: number;\n};\n\n/**\n * A row of cards with attention on a schedule.\n *\n * On a website this is a hover effect, which means it is the viewer's job to\n * run it. Video has no cursor, so the light walks the cards itself: it dwells,\n * then travels, and the card underneath it lifts a few pixels while the others\n * step back a shade. The dimming matters as much as the light — a spotlight\n * over uniformly bright cards says nothing.\n *\n * The light is one gradient over the whole grid rather than a glow per card,\n * so the travel between cards is a real journey across the surface instead of\n * a cross-fade pretending to be one.\n */\nexport const SpotlightCard = ({items, dwellFrames = 50, travelFrames = 14, columns}: SpotlightCardProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const list: SpotlightItem[] =\n items && items.length > 0\n ? items\n : [\n {title: \"Plan\", body: \"Sketch the change\"},\n {title: \"Build\", body: \"Land it in pieces\"},\n {title: \"Ship\", body: \"Watch it hold\"},\n ];\n\n const cols = Math.max(1, columns ?? list.length);\n const rows = Math.ceil(list.length / cols);\n const cycle = dwellFrames + travelFrames;\n const step = Math.floor(frame / cycle) % list.length;\n const next = (step + 1) % list.length;\n const moving = interpolate(frame % cycle, [dwellFrames, cycle], [0, 1], {easing: Easing.standard});\n\n // Card centers in percent of the grid, so the light can travel between them.\n const center = (index: number) => ({\n x: (((index % cols) + 0.5) / cols) * 100,\n y: ((Math.floor(index / cols) + 0.5) / rows) * 100,\n });\n const from = center(step);\n const to = center(next);\n const lightX = from.x + (to.x - from.x) * moving;\n const lightY = from.y + (to.y - from.y) * moving;\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\"}}>\n <div\n style={{\n display: \"grid\",\n gap: 28 * scale,\n gridTemplateColumns: `repeat(${cols}, 1fr)`,\n position: \"relative\",\n width: \"84%\",\n }}\n >\n {list.map((item, index) => {\n // How much of the light this card holds right now.\n const lit = index === step ? 1 - moving : index === next ? moving : 0;\n return (\n <div\n key={`${item.title}-${index}`}\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 24 * scale,\n display: \"grid\",\n gap: 14 * scale,\n minHeight: 260 * scale,\n opacity: 0.6 + lit * 0.4,\n padding: `${40 * scale}px ${38 * scale}px`,\n transform: `translateY(${-lit * 10 * scale}px)`,\n }}\n >\n <div style={{color: brand.colors.foreground, fontSize: 40 * scale, fontWeight: 570, letterSpacing: \"-0.02em\"}}>\n {item.title}\n </div>\n {item.body ? <div style={{color: brand.colors.muted, fontSize: 28 * scale}}>{item.body}</div> : null}\n </div>\n );\n })}\n {/* The one light, drawn over everything so its travel crosses the gaps. */}\n <div\n style={{\n background: `radial-gradient(circle at ${lightX}% ${lightY}%, color-mix(in srgb, ${brand.colors.accent} 22%, transparent), transparent 40%)`,\n inset: 0,\n position: \"absolute\",\n }}\n />\n </div>\n </Fill>\n );\n};\n",
|
|
5148
|
+
"target": "videos/components/spotlight-card/spotlight-card.tsx"
|
|
5149
|
+
},
|
|
5150
|
+
{
|
|
5151
|
+
"path": "components/spotlight-card/spotlight-card.preview.tsx",
|
|
5152
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {SpotlightCard} from \"./spotlight-card\";\n\nexport default defineComponentPreview({\n title: \"Spotlight card\",\n category: \"Interface/Surfaces\",\n description: \"A row of cards that a soft light visits in turn, dwelling and travelling on the frame clock.\",\n component: SpotlightCard,\n canvas: {width: 1920, height: 1080, duration: \"8s\"},\n controls: {\n dwellFrames: {type: \"number\", defaultValue: 50, min: 15, max: 150},\n travelFrames: {type: \"number\", defaultValue: 14, min: 4, max: 45},\n columns: {type: \"number\", defaultValue: 3, min: 1, max: 4},\n },\n examples: [\n {name: \"Default\", props: {}},\n {\n name: \"Four across\",\n props: {\n items: [\n {title: \"Fast\", body: \"Cold starts under 50ms\"},\n {title: \"Typed\", body: \"End to end, no casts\"},\n {title: \"Small\", body: \"12kb on the wire\"},\n {title: \"Yours\", body: \"MIT, no strings\"},\n ],\n dwellFrames: 35,\n },\n },\n {\n name: \"Two by two\",\n props: {\n columns: 2,\n items: [\n {title: \"Ingest\", body: \"Events land in order\"},\n {title: \"Store\", body: \"Columnar, compressed\"},\n {title: \"Query\", body: \"SQL over everything\"},\n {title: \"Alert\", body: \"Before your pager does\"},\n ],\n },\n },\n ],\n});\n",
|
|
5153
|
+
"target": "videos/components/spotlight-card/spotlight-card.preview.tsx"
|
|
5154
|
+
}
|
|
5155
|
+
],
|
|
5156
|
+
"meta": {
|
|
5157
|
+
"kind": "component",
|
|
5158
|
+
"family": "Interface",
|
|
5159
|
+
"namespaced": "@odori/spotlight-card",
|
|
5160
|
+
"contract": {
|
|
5161
|
+
"aspectRatios": [
|
|
5162
|
+
"16:9",
|
|
5163
|
+
"9:16",
|
|
5164
|
+
"1:1"
|
|
5165
|
+
],
|
|
5166
|
+
"recommendedDurationInFrames": 240,
|
|
5167
|
+
"minimumDurationInFrames": 64,
|
|
5168
|
+
"entranceFrames": 0,
|
|
5169
|
+
"exitFrames": 0,
|
|
5170
|
+
"contentLimits": {
|
|
5171
|
+
"items": 4,
|
|
5172
|
+
"title": 20,
|
|
5173
|
+
"body": 48
|
|
5174
|
+
},
|
|
5175
|
+
"reducedMotion": "the light rests on the first card and nothing dims or lifts",
|
|
5176
|
+
"requires": {
|
|
5177
|
+
"fonts": [
|
|
5178
|
+
"sans"
|
|
5179
|
+
],
|
|
5180
|
+
"audio": []
|
|
5181
|
+
}
|
|
5182
|
+
}
|
|
5183
|
+
}
|
|
5184
|
+
},
|
|
3903
5185
|
{
|
|
3904
5186
|
"name": "stage",
|
|
3905
5187
|
"description": "The shared backdrop: hairline grid, soft glow, and vignette.",
|
|
@@ -3983,6 +5265,45 @@
|
|
|
3983
5265
|
}
|
|
3984
5266
|
}
|
|
3985
5267
|
},
|
|
5268
|
+
{
|
|
5269
|
+
"name": "starfield",
|
|
5270
|
+
"description": "A seeded star chart in three depths, drifting on parallax with a few twinkling phases.",
|
|
5271
|
+
"registryDependencies": [],
|
|
5272
|
+
"files": [
|
|
5273
|
+
{
|
|
5274
|
+
"path": "components/starfield/starfield.tsx",
|
|
5275
|
+
"content": "import {Fill, random, randomBetween, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\n/**\n * A seeded star chart in three depths, drifting on parallax.\n *\n * Depth is done honestly: a star's layer sets its size, its brightness, and\n * its drift speed together, so the small dim stars are also the slow ones and\n * the eye reads distance without being told. Every position, layer, and phase\n * comes from a seed named after the star, so the chart is drawn once and\n * forever — the same sky on every machine and in every render chunk.\n *\n * The drift wraps by modular arithmetic on a span slightly wider than the\n * frame, so a star leaves one edge fully before it enters the other and the\n * field never pops. A third of the stars twinkle on their own sine phase; the\n * rest hold steady, because a sky where everything blinks reads as noise.\n */\nexport type StarfieldProps = {\n /** How many stars, across all layers. */\n count?: number;\n /** Which way the sky drifts. */\n drift?: \"up\" | \"left\";\n /** Drift of the nearest layer, in design pixels per second. */\n speed?: number;\n /** How deep the twinkling stars fade, 0 to 1. */\n twinkle?: number;\n /** Star colour. Defaults to the brand's muted tone. */\n color?: string;\n};\n\nexport const Starfield = ({count = 110, drift = \"up\", speed = 14, twinkle = 0.6, color}: StarfieldProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {fps, width, height} = useVideo();\n const ink = color ?? brand.colors.muted;\n const t = frame / fps;\n\n return (\n <Fill style={{background: brand.colors.background, overflow: \"hidden\"}}>\n {Array.from({length: count}, (_, index) => {\n // Three layers, weighted so most stars sit far away: depth 0.35 is the\n // back of the sky, 1 is the front, and everything scales from it.\n const layer = Math.floor(random(`star-${index}-layer`) * 3);\n const depth = [0.35, 0.65, 1][layer];\n const diameter = Math.max(1, randomBetween(`star-${index}-size`, 2, 3.4) * depth * scale);\n const margin = diameter * 2;\n\n const homeX = random(`star-${index}-x`) * width;\n const homeY = random(`star-${index}-y`) * height;\n const travel = t * speed * depth * scale;\n const spanX = width + margin * 2;\n const spanY = height + margin * 2;\n const x = drift === \"left\" ? ((((homeX - travel) % spanX) + spanX) % spanX) - margin : homeX;\n const y = drift === \"up\" ? ((((homeY - travel) % spanY) + spanY) % spanY) - margin : homeY;\n\n const twinkles = random(`star-${index}-flag`) < 0.35;\n const phase = random(`star-${index}-phase`) * Math.PI * 2;\n const rate = randomBetween(`star-${index}-rate`, 0.15, 0.5) * Math.PI * 2;\n const flicker = twinkles ? 1 - twinkle * (0.5 + 0.5 * Math.sin(t * rate + phase)) : 1;\n\n return (\n <div\n key={index}\n aria-hidden\n style={{\n background: ink,\n borderRadius: \"50%\",\n boxShadow: layer === 2 ? `0 0 ${6 * scale}px ${ink}` : undefined,\n height: diameter,\n left: 0,\n opacity: 0.85 * depth * flicker,\n position: \"absolute\",\n top: 0,\n transform: `translate(${x}px, ${y}px)`,\n width: diameter,\n }}\n />\n );\n })}\n </Fill>\n );\n};\n",
|
|
5276
|
+
"target": "videos/components/starfield/starfield.tsx"
|
|
5277
|
+
},
|
|
5278
|
+
{
|
|
5279
|
+
"path": "components/starfield/starfield.preview.tsx",
|
|
5280
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Starfield} from \"./starfield\";\n\nexport default defineComponentPreview({\n title: \"Starfield\",\n category: \"Backgrounds\",\n description: \"A seeded star chart in three depths, drifting on parallax with a few twinkling phases.\",\n component: Starfield,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n count: {type: \"number\", defaultValue: 110, min: 20, max: 300, step: 10},\n twinkle: {type: \"number\", defaultValue: 0.6, min: 0, max: 1, step: 0.05},\n drift: {type: \"select\", defaultValue: \"up\", options: [\"up\", \"left\"]},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Sideways\", props: {drift: \"left\", speed: 26}},\n {name: \"Dense and still\", props: {count: 240, speed: 0, twinkle: 0.4}},\n ],\n});\n",
|
|
5281
|
+
"target": "videos/components/starfield/starfield.preview.tsx"
|
|
5282
|
+
}
|
|
5283
|
+
],
|
|
5284
|
+
"meta": {
|
|
5285
|
+
"kind": "component",
|
|
5286
|
+
"family": "Backgrounds",
|
|
5287
|
+
"namespaced": "@odori/starfield",
|
|
5288
|
+
"contract": {
|
|
5289
|
+
"aspectRatios": [
|
|
5290
|
+
"16:9",
|
|
5291
|
+
"9:16",
|
|
5292
|
+
"1:1"
|
|
5293
|
+
],
|
|
5294
|
+
"recommendedDurationInFrames": 180,
|
|
5295
|
+
"minimumDurationInFrames": 30,
|
|
5296
|
+
"entranceFrames": 0,
|
|
5297
|
+
"exitFrames": 0,
|
|
5298
|
+
"contentLimits": {},
|
|
5299
|
+
"reducedMotion": "set speed and twinkle to 0 and the sky is a still seeded star chart",
|
|
5300
|
+
"requires": {
|
|
5301
|
+
"fonts": [],
|
|
5302
|
+
"audio": []
|
|
5303
|
+
}
|
|
5304
|
+
}
|
|
5305
|
+
}
|
|
5306
|
+
},
|
|
3986
5307
|
{
|
|
3987
5308
|
"name": "state-transition",
|
|
3988
5309
|
"description": "Before, processing, and resolved product states.",
|
|
@@ -4262,7 +5583,7 @@
|
|
|
4262
5583
|
"files": [
|
|
4263
5584
|
{
|
|
4264
5585
|
"path": "components/tabs-switch/tabs-switch.tsx",
|
|
4265
|
-
"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:
|
|
5586
|
+
"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: brand.colors.background, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n background: brand.colors.background,\n border: `${px(1)}px solid ${brand.colors.border}`,\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 ${brand.colors.border}`, 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",
|
|
4266
5587
|
"target": "videos/components/tabs-switch/tabs-switch.tsx"
|
|
4267
5588
|
},
|
|
4268
5589
|
{
|
|
@@ -4295,6 +5616,45 @@
|
|
|
4295
5616
|
}
|
|
4296
5617
|
}
|
|
4297
5618
|
},
|
|
5619
|
+
{
|
|
5620
|
+
"name": "tape-static",
|
|
5621
|
+
"description": "Chunky seeded squares falling slowly on per-flake sway, wrapping so the snow never ends.",
|
|
5622
|
+
"registryDependencies": [],
|
|
5623
|
+
"files": [
|
|
5624
|
+
{
|
|
5625
|
+
"path": "components/tape-static/tape-static.tsx",
|
|
5626
|
+
"content": "import {Fill, random, randomBetween, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\n/**\n * Chunky seeded pixels falling slowly, each on its own sway.\n *\n * The squares are deliberately square — snapped to whole pixels, no radius, no\n * blur — because the charm is the crunch. A flake's depth seed drives its\n * size, its opacity, and its fall rate together, so the small dim flakes are\n * also the slow ones and the field reads as deep without parallax layers.\n *\n * The fall wraps by modular arithmetic over the frame height plus a margin:\n * a flake leaves the bottom edge fully before re-entering above the top, so\n * the snow never ends and never pops. Everything is a function of the frame\n * and the flake's seeds, so every machine renders the same storm.\n */\nexport type TapeStaticProps = {\n /** How many flakes. */\n count?: number;\n /** Side of the largest flake, in design pixels. */\n size?: number;\n /** Fall speed multiplier. 1 is a lazy drift. */\n speed?: number;\n /** Flake colour. Defaults to the brand's muted tone. */\n color?: string;\n /** Opacity of the nearest flake. */\n opacity?: number;\n};\n\nexport const TapeStatic = ({count = 70, size = 10, speed = 1, color, opacity = 0.7}: TapeStaticProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {fps, width, height} = useVideo();\n const ink = color ?? brand.colors.muted;\n const t = frame / fps;\n\n return (\n <Fill style={{background: brand.colors.background, overflow: \"hidden\"}}>\n {Array.from({length: count}, (_, index) => {\n const depth = randomBetween(`flake-${index}-depth`, 0.45, 1);\n const side = Math.max(2, Math.round(size * depth * scale));\n\n // The wrap span covers the frame plus one flake above and below, so\n // the modulo seam sits entirely off screen.\n const span = height + side * 2;\n const start = random(`flake-${index}-y`) * span;\n const fall = randomBetween(`flake-${index}-fall`, 28, 55) * speed * depth * scale;\n const y = ((start + t * fall) % span) - side;\n\n const homeX = random(`flake-${index}-x`) * width;\n const phase = random(`flake-${index}-phase`) * Math.PI * 2;\n const swayRate = randomBetween(`flake-${index}-rate`, 0.08, 0.22) * Math.PI * 2;\n const swayAmp = randomBetween(`flake-${index}-sway`, 8, 26) * scale;\n const x = homeX + Math.sin(t * swayRate + phase) * swayAmp;\n\n return (\n <div\n key={index}\n aria-hidden\n style={{\n background: ink,\n height: side,\n left: 0,\n opacity: opacity * depth,\n position: \"absolute\",\n top: 0,\n transform: `translate(${x}px, ${y}px)`,\n width: side,\n }}\n />\n );\n })}\n </Fill>\n );\n};\n",
|
|
5627
|
+
"target": "videos/components/tape-static/tape-static.tsx"
|
|
5628
|
+
},
|
|
5629
|
+
{
|
|
5630
|
+
"path": "components/tape-static/tape-static.preview.tsx",
|
|
5631
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {TapeStatic} from \"./tape-static\";\n\nexport default defineComponentPreview({\n title: \"Tape static\",\n category: \"Backgrounds\",\n description: \"Chunky seeded squares falling slowly on per-flake sway, wrapping so the snow never ends.\",\n component: TapeStatic,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n count: {type: \"number\", defaultValue: 70, min: 10, max: 240, step: 10},\n size: {type: \"number\", defaultValue: 10, min: 2, max: 32, step: 1},\n speed: {type: \"number\", defaultValue: 1, min: 0, max: 4, step: 0.25},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Blizzard\", props: {count: 200, size: 6, speed: 2.5, opacity: 0.55}},\n {name: \"Confetti drift\", props: {color: \"#ff9a3d\", count: 40, size: 14, speed: 0.6}},\n ],\n});\n",
|
|
5632
|
+
"target": "videos/components/tape-static/tape-static.preview.tsx"
|
|
5633
|
+
}
|
|
5634
|
+
],
|
|
5635
|
+
"meta": {
|
|
5636
|
+
"kind": "component",
|
|
5637
|
+
"family": "Backgrounds",
|
|
5638
|
+
"namespaced": "@odori/tape-static",
|
|
5639
|
+
"contract": {
|
|
5640
|
+
"aspectRatios": [
|
|
5641
|
+
"16:9",
|
|
5642
|
+
"9:16",
|
|
5643
|
+
"1:1"
|
|
5644
|
+
],
|
|
5645
|
+
"recommendedDurationInFrames": 180,
|
|
5646
|
+
"minimumDurationInFrames": 30,
|
|
5647
|
+
"entranceFrames": 0,
|
|
5648
|
+
"exitFrames": 0,
|
|
5649
|
+
"contentLimits": {},
|
|
5650
|
+
"reducedMotion": "set speed to 0 and every flake holds its seeded position",
|
|
5651
|
+
"requires": {
|
|
5652
|
+
"fonts": [],
|
|
5653
|
+
"audio": []
|
|
5654
|
+
}
|
|
5655
|
+
}
|
|
5656
|
+
}
|
|
5657
|
+
},
|
|
4298
5658
|
{
|
|
4299
5659
|
"name": "teams",
|
|
4300
5660
|
"description": "A Teams channel where an app answers a post with a card.",
|
|
@@ -4386,7 +5746,7 @@
|
|
|
4386
5746
|
"files": [
|
|
4387
5747
|
{
|
|
4388
5748
|
"path": "components/terminal/terminal.tsx",
|
|
4389
|
-
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type TerminalStep = {command: string; output?: string[]; durationInFrames?: number};\n\nexport type TerminalProps = {\n steps: TerminalStep[];\n title?: string;\n prompt?: string;\n};\n\n/**\n * A typed session. Characters, the caret blink, and output all derive from the\n * frame, so scrubbing backwards untypes the command exactly.\n */\nexport const Terminal = ({steps, title = \"product-stories\", prompt = \"$\"}: TerminalProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const enter = interpolate(frame, [0, 20], [0, 1], {easing: Easing.standard});\n\n let cursor = 0;\n const rendered = steps.map((step) => {\n const typing = Math.max(14, Math.round(step.command.length * 1.5));\n const hold = step.durationInFrames ?? typing + 40;\n const start = cursor;\n cursor += hold;\n const local = frame - start;\n return {\n step,\n visible: local >= 0,\n typed: Math.max(0, Math.min(step.command.length, Math.round(local / 1.5))),\n typing,\n local,\n outputProgress: interpolate(local, [typing + 6, typing + 20], [0, 1], {easing: Easing.standard}),\n };\n });\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", padding: 110 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n boxShadow: `0 ${30 * scale}px ${90 * scale}px
|
|
5749
|
+
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type TerminalStep = {command: string; output?: string[]; durationInFrames?: number};\n\n/** Close, minimise, zoom — the colours a window has had for twenty years. */\nconst TRAFFIC = [\"#FF5F57\", \"#FEBC2E\", \"#28C840\"];\n\nexport type TerminalProps = {\n steps: TerminalStep[];\n title?: string;\n prompt?: string;\n /** Window buttons in the brand's own surface colour rather than in red,\n amber, and green. */\n monochrome?: boolean;\n};\n\n/**\n * A typed session. Characters, the caret blink, and output all derive from the\n * frame, so scrubbing backwards untypes the command exactly.\n */\nexport const Terminal = ({steps, title = \"product-stories\", prompt = \"$\", monochrome = false}: TerminalProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const enter = interpolate(frame, [0, 20], [0, 1], {easing: Easing.standard});\n\n let cursor = 0;\n const rendered = steps.map((step) => {\n const typing = Math.max(14, Math.round(step.command.length * 1.5));\n const hold = step.durationInFrames ?? typing + 40;\n const start = cursor;\n cursor += hold;\n const local = frame - start;\n return {\n step,\n visible: local >= 0,\n typed: Math.max(0, Math.min(step.command.length, Math.round(local / 1.5))),\n typing,\n local,\n outputProgress: interpolate(local, [typing + 6, typing + 20], [0, 1], {easing: Easing.standard}),\n };\n });\n\n return (\n <Fill style={{alignItems: \"center\", justifyContent: \"center\", padding: 110 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n boxShadow: `0 ${30 * scale}px ${90 * scale}px ${brand.colors.shadow}`,\n fontFamily: brand.typography.mono,\n maxWidth: 1360 * scale,\n minHeight: 540 * scale,\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * 26 * scale}px)`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n background: \"rgba(255,255,255,0.02)\",\n borderBottom: `1px solid ${brand.colors.border}`,\n color: brand.colors.muted,\n display: \"flex\",\n fontSize: 21 * scale,\n gap: 14 * scale,\n padding: `${18 * scale}px ${24 * scale}px`,\n }}\n >\n {/* The window buttons, in the colours every reader already knows.\n They are not brand colours and should not become them: a\n terminal that recolours its close button to match a palette\n stops reading as a terminal, which is the one job this chrome\n has. `monochrome` is there for a brand that wants the quieter\n window. */}\n <div style={{display: \"flex\", gap: 9 * scale}}>\n {(monochrome ? [brand.colors.surface, brand.colors.surface, brand.colors.surface] : TRAFFIC).map(\n (colour, index) => (\n <span\n key={index}\n style={{background: colour, borderRadius: 999, height: 12 * scale, width: 12 * scale}}\n />\n ),\n )}\n </div>\n <span style={{marginLeft: 8 * scale}}>{title}</span>\n </div>\n\n <div style={{display: \"grid\", fontSize: 29 * scale, gap: 20 * scale, lineHeight: 1.5, padding: 32 * scale}}>\n {rendered.map((item, index) =>\n item.visible ? (\n <div key={index} style={{display: \"grid\", gap: 8 * scale}}>\n <div style={{display: \"flex\", gap: 14 * scale}}>\n <span style={{color: brand.colors.muted}}>{prompt}</span>\n <span style={{color: brand.colors.foreground, whiteSpace: \"pre\"}}>\n {item.step.command.slice(0, item.typed)}\n {item.typed < item.step.command.length || Math.floor(item.local / 15) % 2 === 0 ? (\n <span\n style={{\n background: brand.colors.foreground,\n display: \"inline-block\",\n height: 30 * scale,\n marginLeft: 3 * scale,\n transform: `translateY(${5 * scale}px)`,\n width: 13 * scale,\n }}\n />\n ) : null}\n </span>\n </div>\n {item.outputProgress > 0\n ? (item.step.output ?? []).map((line, lineIndex) => (\n <div\n key={lineIndex}\n style={{\n color: brand.colors.muted,\n opacity: item.outputProgress,\n paddingLeft: 32 * scale,\n transform: `translateY(${(1 - item.outputProgress) * 6 * scale}px)`,\n }}\n >\n {line}\n </div>\n ))\n : null}\n </div>\n ) : null,\n )}\n </div>\n </div>\n </Fill>\n );\n};\n",
|
|
4390
5750
|
"target": "videos/components/terminal/terminal.tsx"
|
|
4391
5751
|
},
|
|
4392
5752
|
{
|
|
@@ -4429,7 +5789,7 @@
|
|
|
4429
5789
|
"files": [
|
|
4430
5790
|
{
|
|
4431
5791
|
"path": "components/terminal-zoom/terminal-zoom.tsx",
|
|
4432
|
-
"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:
|
|
5792
|
+
"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: brand.colors.background, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `${px(1)}px solid ${brand.colors.border}`,\n borderRadius: px(16),\n boxShadow: `0 ${px(30)}px ${px(90)}px ${brand.colors.shadow}`,\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 ${brand.colors.border}`,\n color: brand.colors.muted,\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: brand.colors.surface, 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: brand.colors.foreground}}>\n <span style={{color: brand.colors.muted}}>{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: brand.colors.muted,\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",
|
|
4433
5793
|
"target": "videos/components/terminal-zoom/terminal-zoom.tsx"
|
|
4434
5794
|
},
|
|
4435
5795
|
{
|
|
@@ -4558,7 +5918,7 @@
|
|
|
4558
5918
|
"files": [
|
|
4559
5919
|
{
|
|
4560
5920
|
"path": "components/toast-stack/toast-stack.tsx",
|
|
4561
|
-
"content": "import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type Toast = {\n /** Frame it arrives on. */\n at: number;\n title: string;\n detail?: string;\n /** Frames it stays before dismissing. */\n hold?: number;\n tone?: \"neutral\" | \"positive\" | \"negative\";\n};\n\nexport type ToastStackProps = {\n toasts?: Toast[];\n /** Which corner the stack grows from. */\n corner?: \"bottom-right\" | \"top-right\" | \"bottom-left\" | \"top-left\";\n};\n\nconst CORNERS = {\n \"bottom-right\": {bottom: 0, right: 0, column: \"column-reverse\" as const},\n \"top-right\": {top: 0, right: 0, column: \"column\" as const},\n \"bottom-left\": {bottom: 0, left: 0, column: \"column-reverse\" as const},\n \"top-left\": {top: 0, left: 0, column: \"column\" as const},\n};\n\n/**\n * Notifications with the three phases they actually have: entry, hold, and\n * dismissal.\n *\n * Each toast carries its own frames, so the stack is authored as a sequence of\n * events rather than animated by hand. Ones that have dismissed leave the\n * layout, which is what makes the ones below slide up the way a real stack\n * does.\n */\nexport const ToastStack = ({toasts = [], corner = \"bottom-right\"}: ToastStackProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const place = CORNERS[corner];\n const fromRight = corner.endsWith(\"right\");\n\n const tones = {\n neutral: brand.colors.muted,\n positive: brand.colors.accent,\n negative: \"#e5484d\",\n };\n\n return (\n <Fill style={{pointerEvents: \"none\"}}>\n <div\n style={{\n display: \"flex\",\n flexDirection: place.column,\n gap: 14 * scale,\n padding: 48 * scale,\n position: \"absolute\",\n ...place,\n }}\n >\n {toasts.map((toast, index) => {\n const hold = toast.hold ?? 70;\n const enter = interpolate(frame, [toast.at, toast.at + 10], [0, 1], {easing: Easing.standard});\n const leave = interpolate(frame, [toast.at + hold, toast.at + hold + 10], [1, 0], {easing: Easing.standard});\n const visible = Math.min(enter, leave);\n if (visible <= 0.001) return null;\n\n return (\n <div\n key={index}\n style={{\n alignItems: \"flex-start\",\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n boxShadow: `0 ${18 * scale}px ${44 * scale}px
|
|
5921
|
+
"content": "import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type Toast = {\n /** Frame it arrives on. */\n at: number;\n title: string;\n detail?: string;\n /** Frames it stays before dismissing. */\n hold?: number;\n tone?: \"neutral\" | \"positive\" | \"negative\";\n};\n\nexport type ToastStackProps = {\n toasts?: Toast[];\n /** Which corner the stack grows from. */\n corner?: \"bottom-right\" | \"top-right\" | \"bottom-left\" | \"top-left\";\n};\n\nconst CORNERS = {\n \"bottom-right\": {bottom: 0, right: 0, column: \"column-reverse\" as const},\n \"top-right\": {top: 0, right: 0, column: \"column\" as const},\n \"bottom-left\": {bottom: 0, left: 0, column: \"column-reverse\" as const},\n \"top-left\": {top: 0, left: 0, column: \"column\" as const},\n};\n\n/**\n * Notifications with the three phases they actually have: entry, hold, and\n * dismissal.\n *\n * Each toast carries its own frames, so the stack is authored as a sequence of\n * events rather than animated by hand. Ones that have dismissed leave the\n * layout, which is what makes the ones below slide up the way a real stack\n * does.\n */\nexport const ToastStack = ({toasts = [], corner = \"bottom-right\"}: ToastStackProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const place = CORNERS[corner];\n const fromRight = corner.endsWith(\"right\");\n\n const tones = {\n neutral: brand.colors.muted,\n positive: brand.colors.accent,\n negative: \"#e5484d\",\n };\n\n return (\n <Fill style={{pointerEvents: \"none\"}}>\n <div\n style={{\n display: \"flex\",\n flexDirection: place.column,\n gap: 14 * scale,\n padding: 48 * scale,\n position: \"absolute\",\n ...place,\n }}\n >\n {toasts.map((toast, index) => {\n const hold = toast.hold ?? 70;\n const enter = interpolate(frame, [toast.at, toast.at + 10], [0, 1], {easing: Easing.standard});\n const leave = interpolate(frame, [toast.at + hold, toast.at + hold + 10], [1, 0], {easing: Easing.standard});\n const visible = Math.min(enter, leave);\n if (visible <= 0.001) return null;\n\n return (\n <div\n key={index}\n style={{\n alignItems: \"flex-start\",\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n boxShadow: `0 ${18 * scale}px ${44 * scale}px ${brand.colors.shadow}`,\n display: \"flex\",\n gap: 14 * scale,\n // Height collapses with the fade so the stack closes up\n // instead of leaving a hole where a toast used to be.\n maxHeight: 200 * scale * visible,\n opacity: visible,\n overflow: \"hidden\",\n padding: `${20 * scale}px ${24 * scale}px`,\n transform: `translateX(${(1 - visible) * (fromRight ? 40 : -40) * scale}px)`,\n width: 520 * scale,\n }}\n >\n <span\n style={{\n background: tones[toast.tone ?? \"neutral\"],\n borderRadius: 999,\n flex: \"none\",\n height: 10 * scale,\n marginTop: 10 * scale,\n width: 10 * scale,\n }}\n />\n <div style={{display: \"flex\", flexDirection: \"column\", gap: 6 * scale, minWidth: 0}}>\n <strong\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.sans,\n fontSize: 26 * scale,\n fontWeight: 560,\n }}\n >\n {toast.title}\n </strong>\n {toast.detail ? (\n <span style={{color: brand.colors.muted, fontFamily: brand.typography.sans, fontSize: 22 * scale}}>\n {toast.detail}\n </span>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
|
|
4562
5922
|
"target": "videos/components/toast-stack/toast-stack.tsx"
|
|
4563
5923
|
},
|
|
4564
5924
|
{
|
|
@@ -4595,6 +5955,48 @@
|
|
|
4595
5955
|
}
|
|
4596
5956
|
}
|
|
4597
5957
|
},
|
|
5958
|
+
{
|
|
5959
|
+
"name": "topo-lines",
|
|
5960
|
+
"description": "Contour lines of a slowly drifting elevation field, stroked as hairlines whatever the slope.",
|
|
5961
|
+
"registryDependencies": [],
|
|
5962
|
+
"files": [
|
|
5963
|
+
{
|
|
5964
|
+
"path": "components/topo-lines/topo-lines.tsx",
|
|
5965
|
+
"content": "import {Fill, useBrand, useVideo} from \"odori\";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from \"odori/effects\";\n\n/**\n * Contour lines over a drifting elevation, like a survey map of weather.\n *\n * The elevation is fbm noise; slicing its fract() into bands and stroking the\n * band edges is what turns a soft field into drawn lines. The stroke width is\n * measured in screen derivatives (fwidth), so a line is one crisp hairline\n * whether the slope under it is a cliff or a plain — thickness by slope is the\n * artefact that makes cheap contour shaders look like moiré.\n *\n * Lines against a flat ground, nothing filled between the bands: quiet enough\n * to sit under type, structured enough to not read as empty.\n */\nexport type TopoLinesProps = {\n /** Line colour. Defaults to the brand's border. */\n color?: string;\n /** Ground colour. Defaults to the brand's background. */\n background?: string;\n /** How many elevation bands cross the range. */\n lines?: number;\n /** How fast the terrain drifts. */\n speed?: number;\n /** Stroke weight, in multiples of a hairline. */\n weight?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace(\"#\", \"\");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join(\"\") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst field = defineShaderEffect({\n name: \"topo-lines\",\n uniforms: {\n lines: numberUniform(14),\n speed: numberUniform(0.08),\n weight: numberUniform(1),\n ink: vec3Uniform([0.12, 0.12, 0.12]),\n ground: vec3Uniform([0, 0, 0]),\n },\n fragmentShader: `\nuniform float lines;\nuniform float speed;\nuniform float weight;\nuniform vec3 ink;\nuniform vec3 ground;\n\nfloat value(vec2 p) {\n vec2 cell = floor(p);\n vec2 f = fract(p);\n f = f * f * (3.0 - 2.0 * f);\n float a = hash(cell);\n float b = hash(cell + vec2(1.0, 0.0));\n float c = hash(cell + vec2(0.0, 1.0));\n float d = hash(cell + vec2(1.0, 1.0));\n return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);\n}\n\nfloat fbm(vec2 p) {\n float sum = 0.0;\n float weight_ = 0.5;\n for (int i = 0; i < 4; i++) {\n sum += value(p) * weight_;\n p = p * 2.03 + 17.3;\n weight_ *= 0.5;\n }\n return sum;\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n uv.x *= resolution.x / resolution.y;\n float t = seconds * speed;\n\n /* The terrain drifts sideways and breathes a little, rather than boiling:\n contours that reconnect too fast stop reading as a map. */\n float elevation = fbm(uv * 2.6 + vec2(t, t * 0.4));\n elevation += fbm(uv * 5.2 - t * 0.6) * 0.25;\n\n float banded = elevation * lines;\n /* Distance to the nearest band edge, in bands, against how many bands one\n pixel spans — which is what makes the stroke one hairline everywhere. */\n float edge = min(fract(banded), 1.0 - fract(banded));\n float span = fwidth(banded);\n float line = 1.0 - smoothstep(span * weight * 0.5, span * (weight * 0.5 + 1.0), edge);\n\n odoriColour = vec4(mix(ground, ink, line), 1.0);\n}`,\n});\n\nexport const TopoLines = ({color, background, lines = 14, speed = 0.08, weight = 1}: TopoLinesProps) => {\n const brand = useBrand();\n const {width, height} = useVideo();\n\n return (\n <EffectSurface\n effects={[\n field({\n lines,\n speed,\n weight,\n ink: hexToRgb(color ?? brand.colors.muted),\n ground: hexToRgb(background ?? brand.colors.background),\n }),\n ]}\n >\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n",
|
|
5966
|
+
"target": "videos/components/topo-lines/topo-lines.tsx"
|
|
5967
|
+
},
|
|
5968
|
+
{
|
|
5969
|
+
"path": "components/topo-lines/topo-lines.preview.tsx",
|
|
5970
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {TopoLines} from \"./topo-lines\";\n\nexport default defineComponentPreview({\n title: \"Topo lines\",\n category: \"Backgrounds\",\n description: \"Contour lines of a slowly drifting elevation field, stroked as hairlines whatever the slope.\",\n component: TopoLines,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n lines: {type: \"number\", defaultValue: 14, min: 4, max: 40, step: 1},\n speed: {type: \"number\", defaultValue: 0.08, min: 0, max: 0.5, step: 0.02},\n weight: {type: \"number\", defaultValue: 1, min: 0.5, max: 4, step: 0.5},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Dense\", props: {lines: 28, weight: 0.5}},\n {name: \"Chart\", props: {color: \"#1d4044\", background: \"#04100f\", lines: 10, weight: 2}},\n ],\n});\n",
|
|
5971
|
+
"target": "videos/components/topo-lines/topo-lines.preview.tsx"
|
|
5972
|
+
}
|
|
5973
|
+
],
|
|
5974
|
+
"meta": {
|
|
5975
|
+
"kind": "component",
|
|
5976
|
+
"family": "Backgrounds",
|
|
5977
|
+
"namespaced": "@odori/topo-lines",
|
|
5978
|
+
"contract": {
|
|
5979
|
+
"aspectRatios": [
|
|
5980
|
+
"16:9",
|
|
5981
|
+
"9:16",
|
|
5982
|
+
"1:1"
|
|
5983
|
+
],
|
|
5984
|
+
"recommendedDurationInFrames": 180,
|
|
5985
|
+
"minimumDurationInFrames": 30,
|
|
5986
|
+
"entranceFrames": 0,
|
|
5987
|
+
"exitFrames": 0,
|
|
5988
|
+
"contentLimits": {},
|
|
5989
|
+
"reducedMotion": "set speed to 0 and the contours hold one still map",
|
|
5990
|
+
"requires": {
|
|
5991
|
+
"fonts": [],
|
|
5992
|
+
"audio": [],
|
|
5993
|
+
"graphics": [
|
|
5994
|
+
"webgl2"
|
|
5995
|
+
]
|
|
5996
|
+
}
|
|
5997
|
+
}
|
|
5998
|
+
}
|
|
5999
|
+
},
|
|
4598
6000
|
{
|
|
4599
6001
|
"name": "typewriter",
|
|
4600
6002
|
"description": "Deterministic typing with a frame-driven caret.",
|
|
@@ -4838,6 +6240,45 @@
|
|
|
4838
6240
|
}
|
|
4839
6241
|
}
|
|
4840
6242
|
},
|
|
6243
|
+
{
|
|
6244
|
+
"name": "volumetrics",
|
|
6245
|
+
"description": "Blurred translucent beams from a corner or the top edge, breathing on offset phases.",
|
|
6246
|
+
"registryDependencies": [],
|
|
6247
|
+
"files": [
|
|
6248
|
+
{
|
|
6249
|
+
"path": "components/volumetrics/volumetrics.tsx",
|
|
6250
|
+
"content": "import {Fill, random, randomBetween, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\n/**\n * A handful of translucent beams from one corner or the top edge, the way\n * light falls into a room with dust in the air.\n *\n * Each beam is a tall gradient bar rotated about its source, blurred until the\n * edges stop being edges. The beams do not sweep — a searchlight is an event,\n * not a ground — they breathe: width and opacity cycle slowly on phases each\n * beam takes from its own seed, so the light shifts the way daylight does and\n * never in unison.\n *\n * Everything fades along its length, so the frame's lower half stays clean for\n * whatever sits on top.\n */\nexport type VolumetricsProps = {\n /** Where the light enters. */\n from?: \"top\" | \"top-left\" | \"top-right\";\n /** Beam colour. Defaults to the brand's accent. */\n color?: string;\n /** How many beams, 3 to 5. */\n rays?: number;\n /** Opacity ceiling for the strongest beam. */\n intensity?: number;\n /** How fast the beams breathe. */\n speed?: number;\n};\n\nexport const Volumetrics = ({from = \"top-left\", color, rays = 4, intensity = 0.45, speed = 1}: VolumetricsProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {fps, width, height} = useVideo();\n const beam = color ?? brand.colors.accent;\n const count = Math.max(3, Math.min(5, Math.round(rays)));\n const t = (frame / fps) * speed;\n\n // The fan: beams from a corner lean into the frame, beams from the top edge\n // hang nearly straight with a slight spread.\n const origin =\n from === \"top\"\n ? {x: width / 2, base: 0, spread: 26}\n : {x: from === \"top-left\" ? 0 : width, base: from === \"top-left\" ? -32 : 32, spread: 30};\n const length = Math.hypot(width, height) * 1.2;\n\n return (\n <Fill style={{background: brand.colors.background, overflow: \"hidden\"}}>\n {Array.from({length: count}, (_, index) => {\n const along = index / (count - 1);\n const angle = origin.base + (along - 0.5) * origin.spread + randomBetween(`ray-${index}-angle`, -3, 3);\n const phase = random(`ray-${index}-phase`) * Math.PI * 2;\n const rate = randomBetween(`ray-${index}-rate`, 0.06, 0.12) * Math.PI * 2;\n\n const breathe = 0.5 + 0.5 * Math.sin(t * rate + phase);\n const rayWidth = (110 + 90 * breathe) * scale;\n\n return (\n <div\n key={index}\n aria-hidden\n style={{\n background: `linear-gradient(180deg, ${beam} 0%, transparent 70%)`,\n filter: `blur(${36 * scale}px)`,\n height: length,\n left: origin.x + (from === \"top\" ? (along - 0.5) * width * 0.7 : 0) - rayWidth / 2,\n opacity: intensity * (0.45 + 0.55 * breathe),\n position: \"absolute\",\n top: -60 * scale,\n transform: `rotate(${angle}deg)`,\n transformOrigin: \"top center\",\n width: rayWidth,\n }}\n />\n );\n })}\n </Fill>\n );\n};\n",
|
|
6251
|
+
"target": "videos/components/volumetrics/volumetrics.tsx"
|
|
6252
|
+
},
|
|
6253
|
+
{
|
|
6254
|
+
"path": "components/volumetrics/volumetrics.preview.tsx",
|
|
6255
|
+
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Volumetrics} from \"./volumetrics\";\n\nexport default defineComponentPreview({\n title: \"Volumetrics\",\n category: \"Backgrounds\",\n description: \"Blurred translucent beams from a corner or the top edge, breathing on offset phases.\",\n component: Volumetrics,\n canvas: {width: 1920, height: 1080, duration: \"6s\"},\n controls: {\n from: {type: \"select\", defaultValue: \"top-left\", options: [\"top\", \"top-left\", \"top-right\"]},\n intensity: {type: \"number\", defaultValue: 0.45, min: 0, max: 0.8, step: 0.05},\n speed: {type: \"number\", defaultValue: 1, min: 0, max: 3, step: 0.1},\n },\n examples: [\n {name: \"Brand\", props: {}},\n {name: \"Overhead\", props: {from: \"top\", rays: 5, intensity: 0.15}},\n {name: \"Amber\", props: {from: \"top-right\", color: \"#ffb454\", intensity: 0.3}},\n ],\n});\n",
|
|
6256
|
+
"target": "videos/components/volumetrics/volumetrics.preview.tsx"
|
|
6257
|
+
}
|
|
6258
|
+
],
|
|
6259
|
+
"meta": {
|
|
6260
|
+
"kind": "component",
|
|
6261
|
+
"family": "Backgrounds",
|
|
6262
|
+
"namespaced": "@odori/volumetrics",
|
|
6263
|
+
"contract": {
|
|
6264
|
+
"aspectRatios": [
|
|
6265
|
+
"16:9",
|
|
6266
|
+
"9:16",
|
|
6267
|
+
"1:1"
|
|
6268
|
+
],
|
|
6269
|
+
"recommendedDurationInFrames": 180,
|
|
6270
|
+
"minimumDurationInFrames": 30,
|
|
6271
|
+
"entranceFrames": 0,
|
|
6272
|
+
"exitFrames": 0,
|
|
6273
|
+
"contentLimits": {},
|
|
6274
|
+
"reducedMotion": "set speed to 0 and the beams hold one still pose",
|
|
6275
|
+
"requires": {
|
|
6276
|
+
"fonts": [],
|
|
6277
|
+
"audio": []
|
|
6278
|
+
}
|
|
6279
|
+
}
|
|
6280
|
+
}
|
|
6281
|
+
},
|
|
4841
6282
|
{
|
|
4842
6283
|
"name": "whip-pan",
|
|
4843
6284
|
"description": "A fast directional camera move with a clean settle.",
|