@odori/cli 0.0.4 → 0.0.6

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.
@@ -797,7 +797,7 @@
797
797
  "files": [
798
798
  {
799
799
  "path": "components/chatgpt/chatgpt.tsx",
800
- "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type ChatGptProps = {\n /** The prompt that gets typed. */\n prompt: string;\n /** What sits in the field before the first character. */\n placeholder?: string;\n /**\n * Starters under the field. They clear as soon as typing starts, the way a\n * suggestion stops being a suggestion once you have your own idea.\n */\n suggestions?: string[];\n /** A mark you have the right to use, drawn above the composer. */\n logo?: ReactNode;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\nconst PALETTE = {\n dark: {page: \"#000000\", field: \"#212121\", edge: \"#303030\", ink: \"#FFFFFF\", faint: \"#9B9B9B\", accent: \"#FFFFFF\"},\n light: {page: \"#FFFFFF\", field: \"#F4F4F4\", edge: \"#E5E5E5\", ink: \"#0D0D0D\", faint: \"#8F8F8F\", accent: \"#0D0D0D\"},\n};\n\n/** ChatGPT sets its interface in the system face. */\nconst FONT = '-apple-system, system-ui, \"Segoe UI\", Helvetica, Arial, sans-serif';\n\n/**\n * A chat composer with starters, taking a prompt.\n *\n * Two things move and they move in sequence: the starters clear, then the\n * send control fills in. Staggering them keeps the frame from changing in two\n * places at once, which is what makes a composer shot read as deliberate\n * rather than busy.\n */\nexport const ChatGpt = ({\n prompt,\n placeholder = \"Ask anything\",\n suggestions = [\"Summarize a document\", \"Analyze data\", \"Make a plan\"],\n logo,\n theme = \"dark\",\n charactersPerSecond = 26,\n}: ChatGptProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const typed = useTyping(prompt, {from: 20, charactersPerSecond, chunk: 2});\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});\n // The starters leave first, then the control arms: one change at a time.\n const cleared = interpolate(typed.length > 0 ? frame : 0, [20, 30], [0, 1], {easing: Easing.standard});\n const armed = interpolate(typed.length > 0 ? frame : 0, [26, 34], [0, 1], {easing: Easing.standard});\n const onDark = theme === \"dark\";\n\n return (\n <Fill style={{alignItems: \"center\", background: colors.page, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n maxWidth: px(1120),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(18)}px)`,\n width: \"100%\",\n }}\n >\n {logo ? <div style={{marginBottom: px(40), textAlign: \"center\"}}>{logo}</div> : null}\n\n <div\n style={{\n alignItems: \"center\",\n background: colors.field,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n display: \"flex\",\n gap: px(18),\n padding: `${px(20)}px ${px(20)}px ${px(20)}px ${px(32)}px`,\n }}\n >\n <span\n style={{\n color: typed.length > 0 ? colors.ink : colors.faint,\n flex: 1,\n fontFamily: FONT,\n fontSize: px(32),\n letterSpacing: \"-0.01em\",\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {typed.length > 0 ? typed.text : placeholder}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(32),\n marginLeft: px(4),\n transform: `translateY(${px(5)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </span>\n\n {/* The idle microphone gives way to a filled send control. */}\n <span\n style={{\n alignItems: \"center\",\n background: `color-mix(in srgb, ${colors.accent} ${armed * 100}%, transparent)`,\n borderRadius: px(999),\n display: \"inline-flex\",\n flex: \"none\",\n height: px(56),\n justifyContent: \"center\",\n position: \"relative\",\n width: px(56),\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(26)} height={px(26)} style={{opacity: 1 - armed, position: \"absolute\"}}>\n <rect x=\"9\" y=\"3\" width=\"6\" height=\"11\" rx=\"3\" fill={colors.faint} />\n <path\n d=\"M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3\"\n fill=\"none\"\n stroke={colors.faint}\n strokeLinecap=\"round\"\n strokeWidth={1.8}\n />\n </svg>\n <svg\n viewBox=\"0 0 24 24\"\n width={px(24)}\n height={px(24)}\n style={{opacity: armed, position: \"absolute\", transform: `scale(${0.7 + armed * 0.3})`}}\n >\n <path\n d=\"M12 19V5M12 5l-6 6M12 5l6 6\"\n fill=\"none\"\n stroke={onDark ? \"#0D0D0D\" : \"#FFFFFF\"}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.4}\n />\n </svg>\n </span>\n </div>\n\n {suggestions.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: px(14),\n justifyContent: \"center\",\n marginTop: px(28),\n opacity: 1 - cleared,\n transform: `translateY(${cleared * px(-10)}px)`,\n }}\n >\n {suggestions.map((suggestion) => (\n <span\n key={suggestion}\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n color: colors.faint,\n fontFamily: FONT,\n fontSize: px(22),\n padding: `${px(12)}px ${px(22)}px`,\n }}\n >\n {suggestion}\n </span>\n ))}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
800
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type ChatGptProps = {\n /** The prompt that gets typed. */\n prompt: string;\n /** What sits in the field before the first character. */\n placeholder?: string;\n /**\n * Starters under the field. They clear as soon as typing starts, the way a\n * suggestion stops being a suggestion once you have your own idea.\n */\n suggestions?: string[];\n /** A mark you have the right to use, drawn above the composer. */\n logo?: ReactNode;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\nconst PALETTE = {\n dark: {page: \"#000000\", field: \"#212121\", edge: \"#303030\", ink: \"#FFFFFF\", faint: \"#9B9B9B\", accent: \"#FFFFFF\"},\n light: {page: \"#FCFCFC\", field: \"#FFFFFF\", edge: \"#E5E5E5\", ink: \"#0D0D0D\", faint: \"#8F8F8F\", accent: \"#0D0D0D\"},\n};\n\n/** ChatGPT sets its interface in the system face. */\nconst FONT = '-apple-system, system-ui, \"Segoe UI\", Helvetica, Arial, sans-serif';\n\n/**\n * A chat composer with starters, taking a prompt.\n *\n * Two things move and they move in sequence: the starters clear, then the\n * send control fills in. Staggering them keeps the frame from changing in two\n * places at once, which is what makes a composer shot read as deliberate\n * rather than busy.\n */\nexport const ChatGpt = ({\n prompt,\n placeholder = \"Ask anything\",\n suggestions = [\"Summarize a document\", \"Analyze data\", \"Make a plan\"],\n logo,\n theme = \"dark\",\n charactersPerSecond = 26,\n}: ChatGptProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const typed = useTyping(prompt, {from: 20, charactersPerSecond, chunk: 2});\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});\n // The starters leave first, then the control arms: one change at a time.\n const cleared = interpolate(typed.length > 0 ? frame : 0, [20, 30], [0, 1], {easing: Easing.standard});\n const armed = interpolate(typed.length > 0 ? frame : 0, [26, 34], [0, 1], {easing: Easing.standard});\n const onDark = theme === \"dark\";\n\n return (\n <Fill style={{alignItems: \"center\", background: colors.page, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n maxWidth: px(1120),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(18)}px)`,\n width: \"100%\",\n }}\n >\n {logo ? <div style={{marginBottom: px(40), textAlign: \"center\"}}>{logo}</div> : null}\n\n <div\n style={{\n alignItems: \"center\",\n background: colors.field,\n /* ChatGPT rings the composer with a shadow rather than a border:\n a hairline at 4%, a short drop, and a very wide soft one. A\n 1px border reads harder than any of them. */\n border: theme === \"dark\" ? `${px(1)}px solid ${colors.edge}` : undefined,\n borderRadius: px(45),\n boxShadow:\n theme === \"dark\"\n ? undefined\n : `0 0 0 ${px(1.6)}px rgba(0,0,0,0.04), 0 ${px(3)}px ${px(13)}px rgba(0,0,0,0.04), 0 ${px(6)}px ${px(128)}px ${px(13)}px rgba(0,0,0,0.02)`,\n display: \"flex\",\n gap: px(18),\n padding: `${px(20)}px ${px(20)}px ${px(20)}px ${px(32)}px`,\n }}\n >\n <span\n style={{\n color: typed.length > 0 ? colors.ink : colors.faint,\n flex: 1,\n fontFamily: FONT,\n fontSize: px(32),\n letterSpacing: \"-0.01em\",\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {typed.length > 0 ? typed.text : placeholder}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(32),\n marginLeft: px(4),\n transform: `translateY(${px(5)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </span>\n\n {/* The idle microphone gives way to a filled send control. */}\n <span\n style={{\n alignItems: \"center\",\n background: `color-mix(in srgb, ${colors.accent} ${armed * 100}%, transparent)`,\n borderRadius: px(999),\n display: \"inline-flex\",\n flex: \"none\",\n height: px(56),\n justifyContent: \"center\",\n position: \"relative\",\n width: px(56),\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(26)} height={px(26)} style={{opacity: 1 - armed, position: \"absolute\"}}>\n <rect x=\"9\" y=\"3\" width=\"6\" height=\"11\" rx=\"3\" fill={colors.faint} />\n <path\n d=\"M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3\"\n fill=\"none\"\n stroke={colors.faint}\n strokeLinecap=\"round\"\n strokeWidth={1.8}\n />\n </svg>\n <svg\n viewBox=\"0 0 24 24\"\n width={px(24)}\n height={px(24)}\n style={{opacity: armed, position: \"absolute\", transform: `scale(${0.7 + armed * 0.3})`}}\n >\n <path\n d=\"M12 19V5M12 5l-6 6M12 5l6 6\"\n fill=\"none\"\n stroke={onDark ? \"#0D0D0D\" : \"#FFFFFF\"}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.4}\n />\n </svg>\n </span>\n </div>\n\n {suggestions.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: px(14),\n justifyContent: \"center\",\n marginTop: px(28),\n opacity: 1 - cleared,\n transform: `translateY(${cleared * px(-10)}px)`,\n }}\n >\n {suggestions.map((suggestion) => (\n <span\n key={suggestion}\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n color: colors.faint,\n fontFamily: FONT,\n fontSize: px(22),\n padding: `${px(12)}px ${px(22)}px`,\n }}\n >\n {suggestion}\n </span>\n ))}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
801
801
  "target": "videos/components/chatgpt/chatgpt.tsx"
802
802
  },
803
803
  {
@@ -839,7 +839,7 @@
839
839
  "files": [
840
840
  {
841
841
  "path": "components/claude/claude.tsx",
842
- "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type ClaudeProps = {\n /** The prompt that gets typed. */\n prompt: string;\n /** What sits in the field before the first character. */\n placeholder?: string;\n /** The model chip under the field. An empty string hides it. */\n model?: string;\n /**\n * A mark drawn above the composer. Nothing ships here: pass a mark you have\n * the right to use, or leave it out and let the surface speak.\n */\n logo?: ReactNode;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\n/**\n * The surface carries its own palette rather than the brand's: a composer\n * recoloured to your product is no longer a picture of the assistant. Type is\n * the exception, because the brand's sans keeps the cut coherent and nobody\n * reads a UI screenshot for its typeface.\n */\nconst PALETTE = {\n dark: {page: \"#151515\", field: \"rgba(255,255,255,0.06)\", edge: \"#302F2C\", ink: \"#F0EFEC\", faint: \"#8F8B80\", accent: \"#C96442\"},\n light: {page: \"#FAF9F5\", field: \"#FFFFFF\", edge: \"#E3DFD4\", ink: \"#2A2825\", faint: \"#8A8578\", accent: \"#C96442\"},\n};\n\n/** Claude sets its interface in anthropic-sans on a near-black ground. */\nconst FONT = '\"anthropic-sans\", ui-sans-serif, system-ui, -apple-system, sans-serif';\n\n/**\n * An assistant composer with a prompt landing in it.\n *\n * The whole surface is one frame-driven state: characters appear, and the\n * button on the right turns over from the idle voice control into a send\n * arrow the moment there is something to send. That turn is the shot.\n * Everything else holds still so the eye has one thing to follow.\n */\nexport const Claude = ({\n prompt,\n placeholder = \"How can I help you today?\",\n model = \"Claude Opus 4.5\",\n logo,\n theme = \"dark\",\n charactersPerSecond = 26,\n}: ClaudeProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const typed = useTyping(prompt, {from: 14, charactersPerSecond, chunk: 2});\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});\n // The send control turns over once, over six frames from the first\n // character, and stays turned.\n const turn = interpolate(typed.length > 0 ? frame : 0, [14, 20], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", background: colors.page, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n maxWidth: px(1180),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(18)}px)`,\n width: \"100%\",\n }}\n >\n {logo ? <div style={{marginBottom: px(34)}}>{logo}</div> : null}\n\n <div\n style={{\n background: colors.field,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(26),\n boxShadow: `0 ${px(24)}px ${px(70)}px rgba(0,0,0,${theme === \"dark\" ? 0.42 : 0.1})`,\n padding: `${px(30)}px ${px(30)}px ${px(22)}px`,\n }}\n >\n <div\n style={{\n color: typed.length > 0 ? colors.ink : colors.faint,\n fontFamily: FONT,\n fontSize: px(34),\n letterSpacing: \"-0.01em\",\n lineHeight: 1.45,\n minHeight: px(96),\n whiteSpace: \"pre-wrap\",\n }}\n >\n {typed.length > 0 ? typed.text : placeholder}\n {typed.caret ? (\n <span\n style={{\n background: colors.accent,\n display: \"inline-block\",\n height: px(34),\n marginLeft: px(4),\n transform: `translateY(${px(5)}px)`,\n width: px(3),\n }}\n />\n ) : null}\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", justifyContent: \"space-between\", marginTop: px(18)}}>\n {model ? (\n <span\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n color: colors.faint,\n fontFamily: FONT,\n fontSize: px(20),\n padding: `${px(8)}px ${px(16)}px`,\n }}\n >\n {model}\n </span>\n ) : (\n <span />\n )}\n\n {/* One control, two faces. The idle face fades and shrinks as the\n armed face grows through it, so the turn reads as the same\n button changing its mind rather than two buttons swapping. */}\n <span\n style={{\n alignItems: \"center\",\n background: `color-mix(in srgb, ${colors.accent} ${turn * 100}%, transparent)`,\n border: `${px(1)}px solid color-mix(in srgb, ${colors.accent} ${turn * 100}%, ${colors.edge})`,\n borderRadius: px(999),\n display: \"inline-flex\",\n height: px(60),\n justifyContent: \"center\",\n position: \"relative\",\n width: px(60),\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n display: \"flex\",\n gap: px(4),\n opacity: 1 - turn,\n position: \"absolute\",\n transform: `scale(${1 - turn * 0.3})`,\n }}\n >\n {[14, 22, 16].map((height, index) => (\n <span\n key={index}\n style={{background: colors.faint, borderRadius: px(999), height: px(height), width: px(3)}}\n />\n ))}\n </span>\n <svg\n viewBox=\"0 0 24 24\"\n width={px(26)}\n height={px(26)}\n style={{opacity: turn, position: \"absolute\", transform: `scale(${0.7 + turn * 0.3})`}}\n >\n <path\n d=\"M12 19V5M12 5l-6 6M12 5l6 6\"\n fill=\"none\"\n stroke=\"#FFFFFF\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.4}\n />\n </svg>\n </span>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
842
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type ClaudeProps = {\n /** The prompt that gets typed. */\n prompt: string;\n /** What sits in the field before the first character. */\n placeholder?: string;\n /** The model chip under the field. An empty string hides it. */\n model?: string;\n /**\n * A mark drawn above the composer. Nothing ships here: pass a mark you have\n * the right to use, or leave it out and let the surface speak.\n */\n logo?: ReactNode;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\n/**\n * The surface carries its own palette rather than the brand's: a composer\n * recoloured to your product is no longer a picture of the assistant. Type is\n * the exception, because the brand's sans keeps the cut coherent and nobody\n * reads a UI screenshot for its typeface.\n */\nconst PALETTE = {\n dark: {page: \"#151515\", field: \"rgba(255,255,255,0.06)\", edge: \"#302F2C\", ink: \"#F0EFEC\", faint: \"#8F8B80\", accent: \"#C96442\"},\n light: {page: \"#FCFCFB\", field: \"#FFFFFF\", edge: \"#E3DFD4\", ink: \"#0B0B0B\", faint: \"#8A8578\", accent: \"#C96442\"},\n};\n\n/** Claude sets its interface in anthropic-sans on a near-black ground. */\nconst FONT = '\"anthropic-sans\", ui-sans-serif, system-ui, -apple-system, sans-serif';\n\n/**\n * An assistant composer with a prompt landing in it.\n *\n * The whole surface is one frame-driven state: characters appear, and the\n * button on the right turns over from the idle voice control into a send\n * arrow the moment there is something to send. That turn is the shot.\n * Everything else holds still so the eye has one thing to follow.\n */\nexport const Claude = ({\n prompt,\n placeholder = \"How can I help you today?\",\n model = \"Claude Opus 4.5\",\n logo,\n theme = \"dark\",\n charactersPerSecond = 26,\n}: ClaudeProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const typed = useTyping(prompt, {from: 14, charactersPerSecond, chunk: 2});\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});\n // The send control turns over once, over six frames from the first\n // character, and stays turned.\n const turn = interpolate(typed.length > 0 ? frame : 0, [14, 20], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", background: colors.page, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n maxWidth: px(1180),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(18)}px)`,\n width: \"100%\",\n }}\n >\n {logo ? <div style={{marginBottom: px(34)}}>{logo}</div> : null}\n\n <div\n style={{\n background: colors.field,\n /* Claude rings the composer with a half-pixel line and a soft\n drop, not a full border: measured as 0 4px 20px at 7% over\n 0 0 0 0.5px at 30%. A 1px border reads harder than either. */\n border: theme === \"dark\" ? `${px(1)}px solid ${colors.edge}` : undefined,\n borderRadius: px(30),\n boxShadow:\n theme === \"dark\"\n ? `0 ${px(24)}px ${px(70)}px rgba(0,0,0,0.42)`\n : `0 ${px(6)}px ${px(30)}px rgba(0,0,0,0.07), 0 0 0 ${px(0.8)}px rgba(33,33,30,0.30)`,\n padding: `${px(30)}px ${px(30)}px ${px(22)}px`,\n }}\n >\n <div\n style={{\n color: typed.length > 0 ? colors.ink : colors.faint,\n fontFamily: FONT,\n fontSize: px(34),\n letterSpacing: \"-0.01em\",\n lineHeight: 1.33,\n minHeight: px(96),\n whiteSpace: \"pre-wrap\",\n }}\n >\n {typed.length > 0 ? typed.text : placeholder}\n {typed.caret ? (\n <span\n style={{\n background: colors.accent,\n display: \"inline-block\",\n height: px(34),\n marginLeft: px(4),\n transform: `translateY(${px(5)}px)`,\n width: px(3),\n }}\n />\n ) : null}\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", justifyContent: \"space-between\", marginTop: px(18)}}>\n {model ? (\n <span\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n color: colors.faint,\n fontFamily: FONT,\n fontSize: px(20),\n padding: `${px(8)}px ${px(16)}px`,\n }}\n >\n {model}\n </span>\n ) : (\n <span />\n )}\n\n {/* One control, two faces. The idle face fades and shrinks as the\n armed face grows through it, so the turn reads as the same\n button changing its mind rather than two buttons swapping. */}\n <span\n style={{\n alignItems: \"center\",\n background: `color-mix(in srgb, ${colors.accent} ${turn * 100}%, transparent)`,\n border: `${px(1)}px solid color-mix(in srgb, ${colors.accent} ${turn * 100}%, ${colors.edge})`,\n borderRadius: px(999),\n display: \"inline-flex\",\n height: px(60),\n justifyContent: \"center\",\n position: \"relative\",\n width: px(60),\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n display: \"flex\",\n gap: px(4),\n opacity: 1 - turn,\n position: \"absolute\",\n transform: `scale(${1 - turn * 0.3})`,\n }}\n >\n {[14, 22, 16].map((height, index) => (\n <span\n key={index}\n style={{background: colors.faint, borderRadius: px(999), height: px(height), width: px(3)}}\n />\n ))}\n </span>\n <svg\n viewBox=\"0 0 24 24\"\n width={px(26)}\n height={px(26)}\n style={{opacity: turn, position: \"absolute\", transform: `scale(${0.7 + turn * 0.3})`}}\n >\n <path\n d=\"M12 19V5M12 5l-6 6M12 5l6 6\"\n fill=\"none\"\n stroke=\"#FFFFFF\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.4}\n />\n </svg>\n </span>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
843
843
  "target": "videos/components/claude/claude.tsx"
844
844
  },
845
845
  {
@@ -882,7 +882,7 @@
882
882
  "files": [
883
883
  {
884
884
  "path": "components/claude-code/claude-code.tsx",
885
- "content": "import {Easing, Fill, interpolate, useDesignScale, useFrame, useTyping, typingFrames} from \"odori\";\n\n/** A step the agent reports, and what it found underneath. */\nexport type ClaudeCodeStep = {line: string; detail?: string};\n\nexport type ClaudeCodeProps = {\n /** What gets typed at the prompt, in plain English. */\n command: string;\n /** The working directory, as the welcome box states it. */\n cwd?: string;\n /** The model line under the prompt. */\n model?: string;\n /** Steps the agent prints back once the instruction is sent. */\n response?: (ClaudeCodeStep | string)[];\n /** The word the spinner uses while it works. */\n verb?: string;\n charactersPerSecond?: number;\n};\n\nconst INK = \"#E6E4DE\";\nconst FAINT = \"#7C7A73\";\nconst EDGE = \"#34322D\";\nconst ACCENT = \"#C96442\";\n/** A terminal sets its own face; this is the stack a session lands in. */\nconst FONT = '\"SF Mono\", \"JetBrains Mono\", \"Fira Code\", ui-monospace, Menlo, Consolas, monospace';\n/** The frames the spinner cycles, at the rate the real one turns. */\nconst SPINNER = [\"✻\", \"✳\", \"✽\", \"✻\", \"✺\", \"✸\"];\n\n/**\n * Claude Code waking up in a terminal and taking an instruction.\n *\n * The welcome box arrives whole rather than drawing itself in, because a CLI\n * does not animate its own banner and a video that pretends otherwise stops\n * looking like a terminal. The instruction is the only thing moving until it\n * is sent; then the agent's lines print under it one at a time, which is how\n * the tool behaves and the reason to show it rather than a finished\n * transcript.\n *\n * The status line under the prompt is the detail that makes it read as the\n * real thing: a spinner, the seconds it has been going, and the tokens it has\n * spent, all of which the tool keeps in front of you while it works.\n */\nexport const ClaudeCode = ({\n command,\n cwd = \"~/code/odori\",\n model = \"Opus 4.5\",\n response = [],\n verb = \"Working\",\n charactersPerSecond = 22,\n}: ClaudeCodeProps) => {\n const frame = useFrame();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const from = 26;\n const typed = useTyping(command, {from, charactersPerSecond});\n const sentAt = from + typingFrames(command, {charactersPerSecond}) + 14;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const sent = frame >= sentAt;\n\n const steps = response.map((item) => (typeof item === \"string\" ? {line: item} : item));\n\n /* The counters the tool keeps while it works. Both are derived from the\n frame so they are the same on every render of it. */\n const elapsed = Math.max(0, Math.floor((frame - sentAt) / 30));\n const tokens = Math.max(0, Math.round(((frame - sentAt) / 30) * 420));\n const spinner = SPINNER[Math.floor(frame / 4) % SPINNER.length];\n const spent = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${tokens}`;\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#1A1A18\", justifyContent: \"center\", padding: px(110)}}>\n <div\n style={{\n fontFamily: FONT,\n maxWidth: px(1300),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(14)}px)`,\n width: \"100%\",\n }}\n >\n <div style={{border: `${px(1)}px solid ${ACCENT}`, borderRadius: px(12), padding: `${px(26)}px ${px(30)}px`}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(14)}}>\n <span style={{color: ACCENT, fontSize: px(28)}}>✻</span>\n <span style={{color: INK, fontSize: px(28)}}>Welcome to Claude Code!</span>\n </div>\n <div style={{color: FAINT, fontSize: px(22), marginTop: px(18), paddingLeft: px(42)}}>cwd: {cwd}</div>\n </div>\n\n <div style={{color: FAINT, fontSize: px(21), marginTop: px(22), paddingLeft: px(6)}}>\n /help for help, /status for your current setup\n </div>\n\n <div\n style={{\n alignItems: \"center\",\n border: `${px(1)}px solid ${sent ? EDGE : ACCENT}`,\n borderRadius: px(10),\n display: \"flex\",\n gap: px(14),\n marginTop: px(22),\n padding: `${px(20)}px ${px(22)}px`,\n }}\n >\n <span style={{color: ACCENT, fontSize: px(26)}}>&gt;</span>\n <span style={{color: INK, fontSize: px(26), whiteSpace: \"pre\"}}>\n {typed.text}\n {sent ? null : (\n <span\n style={{\n background: typed.caret ? INK : \"transparent\",\n display: \"inline-block\",\n height: px(28),\n transform: `translateY(${px(5)}px)`,\n width: px(13),\n }}\n />\n )}\n </span>\n </div>\n\n {/* The hint row: shortcuts on the left, the mode and model on the\n right, which is where the tool keeps them. */}\n <div\n style={{\n alignItems: \"center\",\n color: FAINT,\n display: \"flex\",\n fontSize: px(19),\n marginTop: px(12),\n padding: `0 ${px(8)}px`,\n }}\n >\n <span>? for shortcuts</span>\n <span style={{marginLeft: \"auto\"}}>{model}</span>\n </div>\n\n {sent ? (\n <div\n style={{\n alignItems: \"center\",\n color: FAINT,\n display: \"flex\",\n fontSize: px(21),\n gap: px(12),\n marginTop: px(24),\n paddingLeft: px(6),\n }}\n >\n <span style={{color: ACCENT}}>{spinner}</span>\n <span style={{color: INK}}>{verb}…</span>\n <span>\n ({elapsed}s · ↑ {spent} tokens · esc to interrupt)\n </span>\n </div>\n ) : null}\n\n {steps.length > 0 ? (\n <div style={{display: \"grid\", gap: px(14), marginTop: px(24), paddingLeft: px(6)}}>\n {steps.map((step, index) => {\n // One line at a time: the agent reports as it goes, and a block\n // arriving at once would read as a transcript instead of work.\n const at = interpolate(frame, [sentAt + 10 + index * 14, sentAt + 22 + index * 14], [0, 1], {\n easing: Easing.standard,\n });\n return (\n <div key={step.line} style={{opacity: at}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(12)}}>\n <span style={{color: ACCENT, fontSize: px(20)}}>⏺</span>\n <span style={{color: INK, fontSize: px(22)}}>{step.line}</span>\n </div>\n {step.detail ? (\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(12), marginTop: px(6), paddingLeft: px(32)}}>\n <span style={{color: FAINT, fontSize: px(20)}}>⎿</span>\n <span style={{color: FAINT, fontSize: px(21)}}>{step.detail}</span>\n </div>\n ) : null}\n </div>\n );\n })}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
885
+ "content": "import {Easing, Fill, interpolate, useDesignScale, useFrame, useTyping, typingFrames} from \"odori\";\n\n/** A step the agent reports, and what it found underneath. */\nexport type ClaudeCodeStep = {line: string; detail?: string};\n\nexport type ClaudeCodeProps = {\n /** What gets typed at the prompt, in plain English. */\n command: string;\n /** The working directory, as the welcome box states it. */\n cwd?: string;\n /** The model line under the prompt. */\n model?: string;\n /** Steps the agent prints back once the instruction is sent. */\n response?: (ClaudeCodeStep | string)[];\n /** The word the spinner uses while it works. */\n verb?: string;\n charactersPerSecond?: number;\n};\n\nconst INK = \"#E6E4DE\";\nconst FAINT = \"#7C7A73\";\nconst EDGE = \"#34322D\";\nconst ACCENT = \"#C96442\";\n/** A terminal sets its own face; this is the stack a session lands in. */\nconst FONT = '\"SF Mono\", \"JetBrains Mono\", \"Fira Code\", ui-monospace, Menlo, Consolas, monospace';\n/** The frames the spinner cycles, at the rate the real one turns. */\nconst SPINNER = [\"✻\", \"✳\", \"✽\", \"✻\", \"✺\", \"✸\"];\n\n/**\n * Claude Code waking up in a terminal and taking an instruction.\n *\n * The welcome box arrives whole rather than drawing itself in, because a CLI\n * does not animate its own banner and a video that pretends otherwise stops\n * looking like a terminal. The instruction is the only thing moving until it\n * is sent; then the agent's lines print under it one at a time, which is how\n * the tool behaves and the reason to show it rather than a finished\n * transcript.\n *\n * The status line under the prompt is the detail that makes it read as the\n * real thing: a spinner, the seconds it has been going, and the tokens it has\n * spent, all of which the tool keeps in front of you while it works.\n */\nexport const ClaudeCode = ({\n command,\n cwd = \"~/code/odori\",\n model = \"Opus 4.5\",\n response = [],\n verb = \"Working\",\n charactersPerSecond = 22,\n}: ClaudeCodeProps) => {\n const frame = useFrame();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const from = 26;\n const typed = useTyping(command, {from, charactersPerSecond});\n const sentAt = from + typingFrames(command, {charactersPerSecond}) + 14;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const sent = frame >= sentAt;\n\n const steps = response.map((item) => (typeof item === \"string\" ? {line: item} : item));\n\n /* The counters the tool keeps while it works. Both are derived from the\n frame so they are the same on every render of it. */\n const elapsed = Math.max(0, Math.floor((frame - sentAt) / 30));\n const tokens = Math.max(0, Math.round(((frame - sentAt) / 30) * 420));\n const spinner = SPINNER[Math.floor(frame / 4) % SPINNER.length];\n const spent = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${tokens}`;\n\n return (\n <Fill style={{alignItems: \"center\", background: \"#1A1A18\", justifyContent: \"center\", padding: px(110)}}>\n <div\n style={{\n fontFamily: FONT,\n maxWidth: px(1300),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(14)}px)`,\n width: \"100%\",\n }}\n >\n <div style={{border: `${px(1)}px solid ${ACCENT}`, borderRadius: px(12), padding: `${px(26)}px ${px(30)}px`}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(14)}}>\n <span style={{color: ACCENT, fontSize: px(28)}}>✻</span>\n <span style={{color: INK, fontSize: px(28)}}>Welcome to Claude Code!</span>\n </div>\n <div style={{color: FAINT, fontSize: px(22), marginTop: px(18), paddingLeft: px(42)}}>cwd: {cwd}</div>\n </div>\n\n <div style={{color: FAINT, fontSize: px(21), marginTop: px(22), paddingLeft: px(6)}}>\n /help for help, /status for your current setup\n </div>\n\n <div\n style={{\n alignItems: \"center\",\n border: `${px(1)}px solid ${sent ? EDGE : ACCENT}`,\n borderRadius: px(10),\n display: \"flex\",\n gap: px(14),\n marginTop: px(22),\n padding: `${px(20)}px ${px(22)}px`,\n }}\n >\n <span style={{color: ACCENT, fontSize: px(26)}}>&gt;</span>\n <span style={{color: INK, fontSize: px(26), whiteSpace: \"pre\"}}>\n {typed.text}\n {sent ? null : (\n <span\n style={{\n background: typed.caret ? INK : \"transparent\",\n display: \"inline-block\",\n height: px(28),\n transform: `translateY(${px(5)}px)`,\n width: px(13),\n }}\n />\n )}\n </span>\n </div>\n\n {/* The hint row: shortcuts on the left, the mode and model on the\n right, which is where the tool keeps them. */}\n <div\n /* The tool's own status line: a shortcut hint and a model name. It\n is there so the frame reads as Claude Code, not so anybody reads\n it. */\n data-odori-chrome\n style={{\n alignItems: \"center\",\n color: FAINT,\n display: \"flex\",\n fontSize: px(19),\n marginTop: px(12),\n padding: `0 ${px(8)}px`,\n }}\n >\n <span>? for shortcuts</span>\n <span style={{marginLeft: \"auto\"}}>{model}</span>\n </div>\n\n {sent ? (\n <div\n style={{\n alignItems: \"center\",\n color: FAINT,\n display: \"flex\",\n fontSize: px(21),\n gap: px(12),\n marginTop: px(24),\n paddingLeft: px(6),\n }}\n >\n <span style={{color: ACCENT}}>{spinner}</span>\n <span style={{color: INK}}>{verb}…</span>\n <span>\n ({elapsed}s · ↑ {spent} tokens · esc to interrupt)\n </span>\n </div>\n ) : null}\n\n {steps.length > 0 ? (\n <div style={{display: \"grid\", gap: px(14), marginTop: px(24), paddingLeft: px(6)}}>\n {steps.map((step, index) => {\n // One line at a time: the agent reports as it goes, and a block\n // arriving at once would read as a transcript instead of work.\n const at = interpolate(frame, [sentAt + 10 + index * 14, sentAt + 22 + index * 14], [0, 1], {\n easing: Easing.standard,\n });\n return (\n <div key={step.line} style={{opacity: at}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(12)}}>\n <span style={{color: ACCENT, fontSize: px(20)}}>⏺</span>\n <span style={{color: INK, fontSize: px(22)}}>{step.line}</span>\n </div>\n {step.detail ? (\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(12), marginTop: px(6), paddingLeft: px(32)}}>\n <span style={{color: FAINT, fontSize: px(20)}}>⎿</span>\n <span style={{color: FAINT, fontSize: px(21)}}>{step.detail}</span>\n </div>\n ) : null}\n </div>\n );\n })}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
886
886
  "target": "videos/components/claude-code/claude-code.tsx"
887
887
  },
888
888
  {
@@ -1623,7 +1623,7 @@
1623
1623
  "files": [
1624
1624
  {
1625
1625
  "path": "components/discord/discord.tsx",
1626
- "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type DiscordPost = {\n author: string;\n body: string;\n time?: string;\n /** Marks the sender as an application. */\n app?: boolean;\n /** The author's role colour, the way a server colours a name. */\n color?: string;\n};\n\nexport type DiscordProps = {\n /** The server name over the channel list. */\n server?: string;\n /** Channels under the text category. */\n channels?: string[];\n /** The channel being read. */\n channel?: string;\n /** The channel topic, beside the name in the header. */\n topic?: string;\n /** Messages already in the channel. */\n history?: DiscordPost[];\n /** The message that gets answered. */\n ask?: DiscordPost;\n /** The bot's reply. It quotes the message it answers. */\n reply?: DiscordPost & {to?: string};\n /** Names in the members list. Hidden when empty. */\n members?: string[];\n thinkingFrames?: number;\n charactersPerSecond?: number;\n};\n\n/** Discord's dark tokens, the same set the desktop and mobile apps share. */\n/** Discord's shipped dark theme, read from the client. */\nconst RAIL = \"#121214\";\nconst SIDEBAR = \"#1A1A1E\";\nconst CHAT = \"#202024\";\nconst COMPOSER = \"#2B2B31\";\nconst ACTIVE = \"#393A41\";\nconst INK = \"#EFEFF1\";\nconst STRONG = \"#FBFBFB\";\nconst MUTED = \"#96979E\";\nconst META = \"#96979E\";\nconst BLURPLE = \"#5865F2\";\nconst FONT = '\"gg sans\", \"Noto Sans\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\nconst ROLES = [\"#5865F2\", \"#EB459E\", \"#57F287\", \"#FEE75C\"];\n\n/**\n * A Discord server with a bot answering in a channel.\n *\n * Discord is three columns and the whole app is the recognition: the server\n * rail, the channel list with one channel lit, and the members list on the\n * right. The reply quotes the message it answers, which is the detail that\n * lets a single frame carry both the question and the answer without a cut.\n */\nexport const Discord = ({\n server = \"Odori\",\n channels = [\"general\", \"support\", \"releases\", \"showcase\"],\n channel = \"support\",\n topic = \"Render questions and bug reports\",\n history = [{author: \"janedoe\", body: \"nightly finished, artifacts are up\", time: \"9:31 AM\", color: ROLES[2]}],\n ask = {author: \"johndoe\", body: \"the nightly render is 40MB heavier than yesterday, anyone seen that?\", time: \"9:38 AM\"},\n reply = {\n author: \"Odori\",\n app: true,\n to: \"johndoe\",\n body: \"Same commit. Yesterday reused cached chunks; today re-encoded at studio quality after the toolchain pin.\",\n time: \"9:39 AM\",\n color: BLURPLE,\n },\n members = [\"Odori\", \"johndoe\", \"janedoe\", \"samdoe\"],\n thinkingFrames = 30,\n charactersPerSecond = 34,\n}: DiscordProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n /* The typing is the person's, not the bot's: they compose in the box and\n press send, and the bot's answer is simply there when it arrives. */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(ask.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 20;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(ask.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const working = frame >= workingAt && frame < replyAt;\n\n /**\n * Discord's proportions, not approximations of them: the avatar is 2.5x\n * the message text and the gutter matches it, which is what makes the\n * avatar hang past the name into the first line rather than sitting\n * centred on the name.\n */\n /**\n * Discord's default avatar is a silhouette on a flat colour, never an\n * initial. An app keeps its own mark, which is how the eye separates the\n * bot from the people in one glance.\n */\n const avatar = (name: string, color: string | undefined, size = 52, app = false) => (\n <span\n style={{\n alignItems: \"center\",\n background: color ?? ROLES[name.length % ROLES.length],\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(size * 0.42),\n fontWeight: 700,\n height: px(size),\n justifyContent: \"center\",\n width: px(size),\n }}\n >\n {app ? (\n name.slice(0, 1).toUpperCase()\n ) : (\n /* The silhouette Discord draws for an account with no picture:\n a head and shoulders, centred, at about half the circle. */\n <svg viewBox=\"0 0 24 24\" width={px(size * 0.62)} height={px(size * 0.62)} fill=\"#FFFFFF\">\n <circle cx=\"12\" cy=\"9\" r=\"4\" />\n <path d=\"M4.6 20a7.6 7.6 0 0 1 14.8 0z\" />\n </svg>\n )}\n </span>\n );\n\n const post = (item: DiscordPost, landed: number, body: ReactNode, key?: string) => (\n <div\n key={key}\n style={{\n display: \"flex\",\n gap: px(21),\n opacity: landed,\n padding: `${px(9)}px ${px(22)}px`,\n transform: `translateY(${(1 - landed) * px(8)}px)`,\n }}\n >\n {avatar(item.author, item.color, 52, item.app)}\n <div style={{minWidth: 0}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(10)}}>\n <span style={{color: item.color ?? ROLES[item.author.length % ROLES.length], fontSize: px(21), fontWeight: 600}}>\n {item.author}\n </span>\n {item.app ? (\n <span\n style={{\n background: BLURPLE,\n borderRadius: px(4),\n color: \"#FFFFFF\",\n fontSize: px(13),\n fontWeight: 600,\n padding: `${px(1)}px ${px(6)}px`,\n }}\n >\n APP\n </span>\n ) : null}\n {item.time ? <span style={{color: MUTED, fontSize: px(16)}}>{item.time}</span> : null}\n </div>\n <div style={{color: INK, fontSize: px(21), lineHeight: 1.45, marginTop: px(3)}}>{body}</div>\n </div>\n </div>\n );\n\n const dots = (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(7), height: px(26)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: MUTED,\n borderRadius: px(999),\n height: px(9),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(9),\n }}\n />\n ))}\n </span>\n );\n\n return (\n <Fill style={{background: \"#000000\", padding: px(60)}}>\n <div\n style={{\n borderRadius: px(14),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The server rail. */}\n <div\n style={{\n alignItems: \"center\",\n background: RAIL,\n display: \"flex\",\n flex: \"none\",\n flexDirection: \"column\",\n gap: px(14),\n padding: `${px(16)}px 0`,\n width: px(84),\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: BLURPLE,\n borderRadius: px(18),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(22),\n fontWeight: 700,\n height: px(52),\n justifyContent: \"center\",\n width: px(52),\n }}\n >\n {server.slice(0, 1).toUpperCase()}\n </span>\n <span style={{background: \"#35373C\", height: px(2), width: px(34)}} />\n {[0, 1].map((item) => (\n <span key={item} style={{background: SIDEBAR, borderRadius: px(999), height: px(52), width: px(52)}} />\n ))}\n </div>\n\n {/* The channel list. */}\n <div style={{background: SIDEBAR, flex: \"none\", width: px(300)}}>\n <div\n style={{\n borderBottom: `${px(1)}px solid ${RAIL}`,\n color: STRONG,\n fontSize: px(21),\n fontWeight: 700,\n padding: `${px(20)}px ${px(20)}px`,\n }}\n >\n {server}\n </div>\n <div\n style={{\n color: MUTED,\n fontSize: px(14),\n fontWeight: 700,\n letterSpacing: \"0.04em\",\n padding: `${px(20)}px ${px(20)}px ${px(8)}px`,\n }}\n >\n TEXT CHANNELS\n </div>\n {channels.map((name) => (\n <div\n key={name}\n style={{\n alignItems: \"center\",\n background: name === channel ? ACTIVE : \"transparent\",\n borderRadius: px(6),\n color: name === channel ? STRONG : MUTED,\n display: \"flex\",\n fontSize: px(19),\n fontWeight: name === channel ? 600 : 400,\n gap: px(8),\n margin: `${px(2)}px ${px(10)}px`,\n padding: `${px(8)}px ${px(10)}px`,\n }}\n >\n <span style={{color: MUTED, fontSize: px(21)}}>#</span>\n {name}\n </div>\n ))}\n </div>\n\n {/* The channel. */}\n <div style={{background: CHAT, display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div\n style={{\n alignItems: \"center\",\n borderBottom: `${px(1)}px solid rgba(0,0,0,0.2)`,\n display: \"flex\",\n gap: px(14),\n padding: `${px(18)}px ${px(22)}px`,\n }}\n >\n <span style={{color: MUTED, fontSize: px(24)}}>#</span>\n <span style={{color: STRONG, fontSize: px(22), fontWeight: 700}}>{channel}</span>\n <span style={{background: \"#3F4147\", height: px(22), width: px(1)}} />\n <span style={{color: MUTED, fontSize: px(17)}}>{topic}</span>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", justifyContent: \"flex-end\", paddingBottom: px(10)}}>\n {history.map((item, index) => post(item, 1, item.body, `${item.author}-${index}`))}\n {post(ask, asked, ask.body)}\n\n {frame >= workingAt ? (\n <div style={{opacity: working ? 1 : replied}}>\n {reply.to && !working ? (\n <div\n style={{\n alignItems: \"center\",\n color: MUTED,\n display: \"flex\",\n fontSize: px(16),\n gap: px(8),\n paddingLeft: px(73),\n }}\n >\n <span\n style={{\n borderLeft: `${px(2)}px solid #4E5058`,\n borderTop: `${px(2)}px solid #4E5058`,\n borderTopLeftRadius: px(8),\n height: px(10),\n marginRight: px(4),\n width: px(24),\n }}\n />\n {avatar(reply.to, ask.color, 20)}\n <span style={{color: ROLES[0]}}>@{reply.to}</span>\n <span style={{overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\"}}>{ask.body}</span>\n </div>\n ) : null}\n {post(\n reply,\n 1,\n working ? dots : reply.body,\n )}\n </div>\n ) : null}\n </div>\n\n {/* The composer, with the controls Discord keeps in it: a plus on\n the left for uploads, and the gift, GIF and emoji row on the\n right. Without them the box reads as a text field, not Discord. */}\n <div\n style={{\n alignItems: \"center\",\n background: COMPOSER,\n borderRadius: px(10),\n display: \"flex\",\n gap: px(16),\n margin: `0 ${px(22)}px ${px(20)}px`,\n padding: `${px(12)}px ${px(18)}px`,\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: MUTED,\n borderRadius: px(999),\n color: COMPOSER,\n display: \"flex\",\n flex: \"none\",\n fontSize: px(22),\n height: px(26),\n justifyContent: \"center\",\n width: px(26),\n }}\n >\n +\n </span>\n <span style={{color: sent ? MUTED : INK, flex: 1, fontSize: px(19), minWidth: 0}}>\n {sent ? (\n `Message #${channel}`\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: INK,\n display: \"inline-block\",\n height: px(20),\n marginLeft: px(2),\n transform: `translateY(${px(3)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </span>\n <span style={{alignItems: \"center\", color: MUTED, display: \"flex\", flex: \"none\", gap: px(14)}}>\n {/* Gift. */}\n <svg viewBox=\"0 0 24 24\" width={px(22)} height={px(22)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8}>\n <path d=\"M4 11h16v9H4zM3 7.5h18V11H3zM12 7.5V20\" strokeLinejoin=\"round\" />\n <path d=\"M12 7.5S10.5 4 8.5 4a2 2 0 0 0 0 3.5zM12 7.5S13.5 4 15.5 4a2 2 0 0 1 0 3.5z\" strokeLinejoin=\"round\" />\n </svg>\n {/* GIF. */}\n <span\n style={{\n border: `${px(1.6)}px solid currentColor`,\n borderRadius: px(4),\n fontSize: px(12),\n fontWeight: 700,\n letterSpacing: \"0.02em\",\n padding: `${px(1)}px ${px(4)}px`,\n }}\n >\n GIF\n </span>\n {/* Emoji. */}\n <svg viewBox=\"0 0 24 24\" width={px(22)} height={px(22)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8}>\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <circle cx=\"9\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <circle cx=\"15\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <path d=\"M8.5 14.5a4.5 4.5 0 0 0 7 0\" strokeLinecap=\"round\" />\n </svg>\n </span>\n </div>\n </div>\n\n {/* The members list. */}\n {members.length > 0 ? (\n <div style={{background: SIDEBAR, flex: \"none\", padding: `${px(20)}px ${px(16)}px`, width: px(260)}}>\n <div style={{color: MUTED, fontSize: px(14), fontWeight: 700, letterSpacing: \"0.04em\", paddingLeft: px(8)}}>\n ONLINE — {members.length}\n </div>\n {members.map((name, index) => (\n <div\n key={name}\n style={{alignItems: \"center\", display: \"flex\", gap: px(12), padding: `${px(9)}px ${px(8)}px`}}\n >\n {avatar(name, index === 0 ? BLURPLE : ROLES[name.length % ROLES.length], 36, index === 0)}\n <span style={{color: index === 0 ? BLURPLE : META, fontSize: px(19)}}>{name}</span>\n {index === 0 ? (\n <span\n style={{\n background: BLURPLE,\n borderRadius: px(4),\n color: \"#FFFFFF\",\n fontSize: px(12),\n fontWeight: 600,\n padding: `${px(1)}px ${px(5)}px`,\n }}\n >\n APP\n </span>\n ) : null}\n </div>\n ))}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
1626
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type DiscordPost = {\n author: string;\n body: string;\n time?: string;\n /** Marks the sender as an application. */\n app?: boolean;\n /** The author's role colour, the way a server colours a name. */\n color?: string;\n};\n\nexport type DiscordProps = {\n /** The server name over the channel list. */\n server?: string;\n /** Channels under the text category. */\n channels?: string[];\n /** The channel being read. */\n channel?: string;\n /** The channel topic, beside the name in the header. */\n topic?: string;\n /** Messages already in the channel. */\n history?: DiscordPost[];\n /** The message that gets answered. */\n ask?: DiscordPost;\n /** The bot's reply. It quotes the message it answers. */\n reply?: DiscordPost & {to?: string};\n /** Names in the members list. Hidden when empty. */\n members?: string[];\n thinkingFrames?: number;\n charactersPerSecond?: number;\n};\n\n/** Discord's dark tokens, the same set the desktop and mobile apps share. */\n/** Discord's shipped dark theme, read from the client. */\nconst RAIL = \"#121214\";\nconst SIDEBAR = \"#1A1A1E\";\nconst CHAT = \"#202024\";\nconst COMPOSER = \"#2B2B31\";\nconst ACTIVE = \"#393A41\";\nconst INK = \"#EFEFF1\";\nconst STRONG = \"#FBFBFB\";\nconst MUTED = \"#96979E\";\n/** Discord inks a channel name a step down from muted: --channels-default. */\nconst CHANNEL = \"#81828A\";\nconst META = \"#96979E\";\nconst BLURPLE = \"#5865F2\";\nconst FONT = '\"gg sans\", \"Noto Sans\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\nconst ROLES = [\"#5865F2\", \"#EB459E\", \"#57F287\", \"#FEE75C\"];\n\n/**\n * A Discord server with a bot answering in a channel.\n *\n * Discord is three columns and the whole app is the recognition: the server\n * rail, the channel list with one channel lit, and the members list on the\n * right. The reply quotes the message it answers, which is the detail that\n * lets a single frame carry both the question and the answer without a cut.\n */\nexport const Discord = ({\n server = \"Odori\",\n channels = [\"general\", \"support\", \"releases\", \"showcase\"],\n channel = \"support\",\n topic = \"Render questions and bug reports\",\n history = [{author: \"janedoe\", body: \"nightly finished, artifacts are up\", time: \"9:31 AM\", color: ROLES[2]}],\n ask = {author: \"johndoe\", body: \"the nightly render is 40MB heavier than yesterday, anyone seen that?\", time: \"9:38 AM\"},\n reply = {\n author: \"Odori\",\n app: true,\n to: \"johndoe\",\n body: \"Same commit. Yesterday reused cached chunks; today re-encoded at studio quality after the toolchain pin.\",\n time: \"9:39 AM\",\n color: BLURPLE,\n },\n members = [\"Odori\", \"johndoe\", \"janedoe\", \"samdoe\"],\n thinkingFrames = 30,\n charactersPerSecond = 34,\n}: DiscordProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n /* The typing is the person's, not the bot's: they compose in the box and\n press send, and the bot's answer is simply there when it arrives. */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(ask.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 20;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(ask.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const working = frame >= workingAt && frame < replyAt;\n\n /**\n * Discord's proportions, not approximations of them: the avatar is 2.5x\n * the message text and the gutter matches it, which is what makes the\n * avatar hang past the name into the first line rather than sitting\n * centred on the name.\n */\n /**\n * Discord's default avatar is a silhouette on a flat colour, never an\n * initial. An app keeps its own mark, which is how the eye separates the\n * bot from the people in one glance.\n */\n const avatar = (name: string, color: string | undefined, size = 52, app = false) => (\n <span\n style={{\n alignItems: \"center\",\n background: color ?? ROLES[name.length % ROLES.length],\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(size * 0.42),\n fontWeight: 700,\n height: px(size),\n justifyContent: \"center\",\n width: px(size),\n }}\n >\n {app ? (\n name.slice(0, 1).toUpperCase()\n ) : (\n /* The silhouette Discord draws for an account with no picture:\n a head and shoulders, centred, at about half the circle. */\n <svg viewBox=\"0 0 24 24\" width={px(size * 0.62)} height={px(size * 0.62)} fill=\"#FFFFFF\">\n <circle cx=\"12\" cy=\"9\" r=\"4\" />\n <path d=\"M4.6 20a7.6 7.6 0 0 1 14.8 0z\" />\n </svg>\n )}\n </span>\n );\n\n const post = (item: DiscordPost, landed: number, body: ReactNode, key?: string) => (\n <div\n key={key}\n style={{\n display: \"flex\",\n gap: px(21),\n opacity: landed,\n padding: `${px(9)}px ${px(22)}px`,\n transform: `translateY(${(1 - landed) * px(8)}px)`,\n }}\n >\n {avatar(item.author, item.color, 52, item.app)}\n <div style={{minWidth: 0}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(10)}}>\n <span style={{color: item.color ?? ROLES[item.author.length % ROLES.length], fontSize: px(21), fontWeight: 600}}>\n {item.author}\n </span>\n {item.app ? (\n <span\n style={{\n background: BLURPLE,\n borderRadius: px(4),\n color: \"#FFFFFF\",\n fontSize: px(13),\n fontWeight: 600,\n padding: `${px(1)}px ${px(6)}px`,\n }}\n >\n APP\n </span>\n ) : null}\n {item.time ? <span style={{color: MUTED, fontSize: px(16)}}>{item.time}</span> : null}\n </div>\n <div style={{color: INK, fontSize: px(21), lineHeight: 1.45, marginTop: px(3)}}>{body}</div>\n </div>\n </div>\n );\n\n const dots = (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(7), height: px(26)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: MUTED,\n borderRadius: px(999),\n height: px(9),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(9),\n }}\n />\n ))}\n </span>\n );\n\n return (\n <Fill style={{background: \"#000000\", padding: px(60)}}>\n <div\n style={{\n borderRadius: px(14),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The server rail. */}\n <div\n style={{\n alignItems: \"center\",\n background: RAIL,\n display: \"flex\",\n flex: \"none\",\n flexDirection: \"column\",\n gap: px(14),\n padding: `${px(16)}px 0`,\n width: px(84),\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: BLURPLE,\n borderRadius: px(18),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(22),\n fontWeight: 700,\n height: px(52),\n justifyContent: \"center\",\n width: px(52),\n }}\n >\n {server.slice(0, 1).toUpperCase()}\n </span>\n <span style={{background: \"#35373C\", height: px(2), width: px(34)}} />\n {[0, 1].map((item) => (\n <span key={item} style={{background: SIDEBAR, borderRadius: px(999), height: px(52), width: px(52)}} />\n ))}\n </div>\n\n {/* The channel list. */}\n <div style={{background: SIDEBAR, flex: \"none\", width: px(300)}}>\n <div\n style={{\n borderBottom: `${px(1)}px solid ${RAIL}`,\n color: STRONG,\n fontSize: px(21),\n fontWeight: 700,\n padding: `${px(20)}px ${px(20)}px`,\n }}\n >\n {server}\n </div>\n <div\n style={{\n color: MUTED,\n fontSize: px(14),\n fontWeight: 700,\n letterSpacing: \"0.04em\",\n padding: `${px(20)}px ${px(20)}px ${px(8)}px`,\n }}\n >\n TEXT CHANNELS\n </div>\n {channels.map((name) => (\n <div\n key={name}\n style={{\n alignItems: \"center\",\n background: name === channel ? ACTIVE : \"transparent\",\n borderRadius: px(6),\n color: name === channel ? STRONG : CHANNEL,\n display: \"flex\",\n fontSize: px(19),\n fontWeight: name === channel ? 600 : 400,\n gap: px(8),\n margin: `${px(2)}px ${px(10)}px`,\n padding: `${px(8)}px ${px(10)}px`,\n }}\n >\n <span style={{color: name === channel ? STRONG : CHANNEL, fontSize: px(21)}}>#</span>\n {name}\n </div>\n ))}\n </div>\n\n {/* The channel. */}\n <div style={{background: CHAT, display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div\n style={{\n alignItems: \"center\",\n borderBottom: `${px(1)}px solid rgba(0,0,0,0.2)`,\n display: \"flex\",\n gap: px(14),\n padding: `${px(18)}px ${px(22)}px`,\n }}\n >\n <span style={{color: MUTED, fontSize: px(24)}}>#</span>\n <span style={{color: STRONG, fontSize: px(22), fontWeight: 700}}>{channel}</span>\n <span style={{background: \"#3F4147\", height: px(22), width: px(1)}} />\n <span style={{color: MUTED, fontSize: px(17)}}>{topic}</span>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", justifyContent: \"flex-end\", paddingBottom: px(10)}}>\n {history.map((item, index) => post(item, 1, item.body, `${item.author}-${index}`))}\n {post(ask, asked, ask.body)}\n\n {frame >= workingAt ? (\n <div style={{opacity: working ? 1 : replied}}>\n {reply.to && !working ? (\n <div\n style={{\n alignItems: \"center\",\n color: MUTED,\n display: \"flex\",\n fontSize: px(16),\n gap: px(8),\n paddingLeft: px(73),\n }}\n >\n <span\n style={{\n borderLeft: `${px(2)}px solid #4E5058`,\n borderTop: `${px(2)}px solid #4E5058`,\n borderTopLeftRadius: px(8),\n height: px(10),\n marginRight: px(4),\n width: px(24),\n }}\n />\n {avatar(reply.to, ask.color, 20)}\n <span style={{color: ROLES[0]}}>@{reply.to}</span>\n <span style={{overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\"}}>{ask.body}</span>\n </div>\n ) : null}\n {post(\n reply,\n 1,\n working ? dots : reply.body,\n )}\n </div>\n ) : null}\n </div>\n\n {/* The composer, with the controls Discord keeps in it: a plus on\n the left for uploads, and the gift, GIF and emoji row on the\n right. Without them the box reads as a text field, not Discord. */}\n <div\n style={{\n alignItems: \"center\",\n background: COMPOSER,\n borderRadius: px(10),\n display: \"flex\",\n gap: px(16),\n margin: `0 ${px(22)}px ${px(20)}px`,\n padding: `${px(12)}px ${px(18)}px`,\n }}\n >\n {/* A drawn plus, not the character: a text glyph carries its\n own ascent and descent and sits low in a round button. */}\n <span\n style={{\n alignItems: \"center\",\n background: MUTED,\n borderRadius: px(999),\n color: COMPOSER,\n display: \"flex\",\n flex: \"none\",\n height: px(26),\n justifyContent: \"center\",\n width: px(26),\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(18)} height={px(18)} fill=\"currentColor\">\n <path d=\"M10.75 3.25a.75.75 0 0 0-1.5 0v6h-6a.75.75 0 0 0 0 1.5h6v6a.75.75 0 0 0 1.5 0v-6h6a.75.75 0 0 0 0-1.5h-6z\" />\n </svg>\n </span>\n <span style={{color: sent ? MUTED : INK, flex: 1, fontSize: px(19), minWidth: 0}}>\n {sent ? (\n `Message #${channel}`\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: INK,\n display: \"inline-block\",\n height: px(20),\n marginLeft: px(2),\n transform: `translateY(${px(3)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </span>\n <span style={{alignItems: \"center\", color: MUTED, display: \"flex\", flex: \"none\", gap: px(14)}}>\n {/* Gift. */}\n <svg viewBox=\"0 0 24 24\" width={px(22)} height={px(22)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8}>\n <path d=\"M4 11h16v9H4zM3 7.5h18V11H3zM12 7.5V20\" strokeLinejoin=\"round\" />\n <path d=\"M12 7.5S10.5 4 8.5 4a2 2 0 0 0 0 3.5zM12 7.5S13.5 4 15.5 4a2 2 0 0 1 0 3.5z\" strokeLinejoin=\"round\" />\n </svg>\n {/* GIF. */}\n <span\n style={{\n border: `${px(1.6)}px solid currentColor`,\n borderRadius: px(4),\n fontSize: px(12),\n fontWeight: 700,\n letterSpacing: \"0.02em\",\n padding: `${px(1)}px ${px(4)}px`,\n }}\n >\n GIF\n </span>\n {/* Emoji. */}\n <svg viewBox=\"0 0 24 24\" width={px(22)} height={px(22)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8}>\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <circle cx=\"9\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <circle cx=\"15\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <path d=\"M8.5 14.5a4.5 4.5 0 0 0 7 0\" strokeLinecap=\"round\" />\n </svg>\n </span>\n </div>\n </div>\n\n {/* The members list. */}\n {members.length > 0 ? (\n <div style={{background: SIDEBAR, flex: \"none\", padding: `${px(20)}px ${px(16)}px`, width: px(260)}}>\n <div style={{color: MUTED, fontSize: px(14), fontWeight: 700, letterSpacing: \"0.04em\", paddingLeft: px(8)}}>\n ONLINE — {members.length}\n </div>\n {members.map((name, index) => (\n <div\n key={name}\n style={{alignItems: \"center\", display: \"flex\", gap: px(12), padding: `${px(9)}px ${px(8)}px`}}\n >\n {avatar(name, index === 0 ? BLURPLE : ROLES[name.length % ROLES.length], 36, index === 0)}\n <span style={{color: index === 0 ? BLURPLE : META, fontSize: px(19)}}>{name}</span>\n {index === 0 ? (\n <span\n style={{\n background: BLURPLE,\n borderRadius: px(4),\n color: \"#FFFFFF\",\n fontSize: px(12),\n fontWeight: 600,\n padding: `${px(1)}px ${px(5)}px`,\n }}\n >\n APP\n </span>\n ) : null}\n </div>\n ))}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
1627
1627
  "target": "videos/components/discord/discord.tsx"
1628
1628
  },
1629
1629
  {
@@ -2018,12 +2018,12 @@
2018
2018
  "files": [
2019
2019
  {
2020
2020
  "path": "components/github-comment/github-comment.tsx",
2021
- "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type GithubCommentProps = {\n /** The repository, as the breadcrumb states it. */\n owner?: string;\n repo?: string;\n /** The pull request. */\n title?: string;\n number?: number;\n /** Branches, for the line under the title. */\n branch?: string;\n base?: string;\n /** The comment that asked for something. */\n request?: {author: string; body: string; time?: string};\n /** Who is answering, and what they say. The body types in. */\n agent?: {name: string; body: string; time?: string};\n /** A suggested change under the reply. */\n suggestion?: {file: string; removed?: string; added?: string};\n /** The check the reply ends on. */\n resolved?: string;\n /** Names in the sidebar's reviewers list. */\n reviewers?: string[];\n labels?: string[];\n thinkingFrames?: number;\n charactersPerSecond?: number;\n};\n\n/** Primer's dark tokens, as GitHub ships them. */\nconst HEADER = \"#151B23\";\nconst CANVAS = \"#212830\";\nconst INSET = \"#262C36\";\nconst EDGE = \"#3D444D\";\nconst INK = \"#D1D7E0\";\nconst MUTED = \"#9198A1\";\nconst LINK = \"#478BE6\";\nconst OPEN = \"#347D39\";\nconst BOT = \"#986EE2\";\nconst FONT = '\"Mona Sans VF\", \"Mona Sans\", -apple-system, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif';\nconst ORANGE = \"#FD8C73\";\n\n/**\n * GitHub's default avatar is an identicon, not a letter.\n *\n * A five by five grid, mirrored down the middle, inset by a margin the way\n * GitHub insets it, with the filled cells and the colour both derived from a\n * hash of the login. GitHub keys it off the account id; the login stands in\n * here, and the only property that matters is the one GitHub relies on: the\n * same name always draws the same mark.\n */\nconst identicon = (name: string, size: number, px: (value: number) => number) => {\n /* FNV-1a, then a mix, because a plain multiply-and-add leaves the low bits\n barely changed between similar logins and every mark comes out alike. */\n let hash = 0x811c9dc5;\n for (const character of name) {\n hash = (hash ^ character.codePointAt(0)!) >>> 0;\n hash = Math.imul(hash, 0x01000193) >>> 0;\n }\n hash = (hash ^ (hash >>> 15)) >>> 0;\n hash = Math.imul(hash, 0x2545f491) >>> 0;\n hash = (hash ^ (hash >>> 13)) >>> 0;\n\n const ink = `hsl(${hash % 360} 58% 50%)`;\n const margin = size / 12;\n const cell = (size - margin * 2) / 5;\n const cells = [];\n for (let column = 0; column < 3; column += 1) {\n for (let row = 0; row < 5; row += 1) {\n /* One bit per cell, taken from a different part of the word each time\n so the three columns do not repeat one another. */\n const bit = (Math.imul(hash, column * 5 + row + 1) >>> ((column * 5 + row) % 13)) & 1;\n if (!bit) continue;\n for (const x of column === 2 ? [2] : [column, 4 - column]) {\n cells.push(\n <rect\n key={`${x}-${row}`}\n x={margin + x * cell}\n y={margin + row * cell}\n width={cell}\n height={cell}\n fill={ink}\n />,\n );\n }\n }\n }\n\n return (\n <span style={{borderRadius: px(999), flex: \"none\", lineHeight: 0, overflow: \"hidden\"}}>\n <svg width={px(size)} height={px(size)} viewBox={`0 0 ${size} ${size}`}>\n <rect width={size} height={size} fill=\"#F0F0F0\" />\n {cells}\n </svg>\n </span>\n );\n};\n\n/**\n * An agent answering on a pull request, on the page the answer appears on.\n *\n * A comment card alone is a screenshot of a comment. The page is what says\n * where the agent is working: the repository above it, the pull request tabs\n * counting what is in review, the timeline the reply joins, and the sidebar\n * that names who is expected to look. The order inside is still the story,\n * though, and it does not change: a person asks, the agent shows it is\n * working, and only then does the answer write itself in.\n */\nexport const GithubComment = ({\n owner = \"johndoe\",\n repo = \"odori\",\n title = \"Pin the render toolchain\",\n number = 128,\n branch = \"fix/toolchain-pin\",\n base = \"main\",\n request = {\n author: \"johndoe\",\n body: \"Why did the export change size between two runs on the same commit?\",\n time: \"9:38 AM\",\n },\n agent = {\n name: \"odori-agent\",\n body: \"The two runs used different FFmpeg builds, so the encoder defaults differed. Pinning both binaries makes the output byte for byte reproducible.\",\n time: \"9:41 AM\",\n },\n suggestion,\n resolved = \"Resolved conversation\",\n reviewers = [\"janedoe\", \"samdoe\"],\n labels = [\"render\", \"bug\"],\n thinkingFrames = 30,\n charactersPerSecond = 34,\n}: GithubCommentProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n /* The typing is the reviewer's: they write the question into the comment\n box and post it, and the agent's answer arrives as a whole comment. */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(request?.body ?? \"\", {charactersPerSecond}) + 10;\n const workingAt = askAt + 18;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(request?.body ?? \"\", {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const answering = spring({frame, fps, delayInFrames: workingAt, stiffness: 150, damping: 16});\n const posted = frame >= askAt;\n const landed = frame >= replyAt;\n const working = frame >= workingAt && frame < replyAt;\n const after = interpolate(landed ? frame : 0, [0, 12], [0, 1], {easing: Easing.standard});\n\n /**\n * A person gets an identicon, the way GitHub draws an account with no\n * picture. An app keeps a solid mark, because that is what a GitHub App\n * shows and it is the fastest way to tell the two apart in a frame.\n */\n const avatar = (name: string, color: string | null, size = 40) =>\n color === null ? (\n identicon(name, size, px)\n ) : (\n <span\n style={{\n alignItems: \"center\",\n background: color,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(size * 0.42),\n fontWeight: 600,\n height: px(size),\n justifyContent: \"center\",\n width: px(size),\n }}\n >\n {name.slice(0, 1).toUpperCase()}\n </span>\n );\n\n /** A comment, drawn the way the timeline draws one: a strip, then a body. */\n const comment = (\n who: {name: string; time?: string; bot?: boolean},\n landed: number,\n body: ReactNode,\n footer?: ReactNode,\n ) => (\n <div\n style={{\n border: `${px(1)}px solid ${who.bot && landed ? BOT : EDGE}`,\n borderRadius: px(10),\n marginTop: px(16),\n opacity: landed,\n overflow: \"hidden\",\n transform: `translateY(${(1 - landed) * px(8)}px)`,\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n background: INSET,\n borderBottom: `${px(1)}px solid ${EDGE}`,\n display: \"flex\",\n gap: px(10),\n padding: `${px(12)}px ${px(16)}px`,\n }}\n >\n {avatar(who.name, who.bot ? BOT : null, 30)}\n <span style={{color: INK, fontSize: px(19), fontWeight: 600}}>{who.name}</span>\n {who.bot ? (\n <span\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(999),\n color: MUTED,\n fontSize: px(14),\n padding: `${px(1)}px ${px(8)}px`,\n }}\n >\n bot\n </span>\n ) : null}\n <span style={{color: MUTED, fontSize: px(17)}}>\n {who.bot && !landed ? \"is working\" : `commented ${who.time ?? \"\"}`}\n </span>\n </div>\n <div style={{padding: `${px(16)}px ${px(18)}px`}}>{body}</div>\n {footer}\n </div>\n );\n\n const dots = (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(7), height: px(26)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: MUTED,\n borderRadius: px(999),\n height: px(9),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(9),\n }}\n />\n ))}\n </span>\n );\n\n const sidebarSection = (heading: string, body: ReactNode) => (\n <div style={{borderBottom: `${px(1)}px solid ${EDGE}`, padding: `${px(18)}px 0`}}>\n <div style={{color: MUTED, fontSize: px(17), fontWeight: 600, marginBottom: px(10)}}>{heading}</div>\n {body}\n </div>\n );\n\n return (\n <Fill style={{background: \"#000000\", padding: px(56)}}>\n <div\n style={{\n background: CANVAS,\n borderRadius: px(12),\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The site header and the repository's own nav. */}\n <div style={{background: HEADER, borderBottom: `${px(1)}px solid ${EDGE}`}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(16), padding: `${px(14)}px ${px(22)}px`}}>\n <span style={{background: INK, borderRadius: px(999), flex: \"none\", height: px(30), width: px(30)}} />\n <span style={{color: INK, fontSize: px(19)}}>\n <span style={{color: LINK}}>{owner}</span>\n <span style={{color: MUTED}}> / </span>\n <span style={{color: LINK, fontWeight: 600}}>{repo}</span>\n </span>\n <span\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(7),\n color: MUTED,\n fontSize: px(16),\n marginLeft: \"auto\",\n padding: `${px(7)}px ${px(60)}px ${px(7)}px ${px(12)}px`,\n }}\n >\n Search\n </span>\n </div>\n <div style={{display: \"flex\", gap: px(22), padding: `0 ${px(22)}px`}}>\n {[\"Code\", \"Issues\", \"Pull requests\", \"Actions\", \"Insights\"].map((tab) => (\n <span\n key={tab}\n style={{\n borderBottom: `${px(2)}px solid ${tab === \"Pull requests\" ? ORANGE : \"transparent\"}`,\n color: tab === \"Pull requests\" ? INK : MUTED,\n fontSize: px(17),\n fontWeight: tab === \"Pull requests\" ? 600 : 400,\n paddingBottom: px(11),\n }}\n >\n {tab}\n </span>\n ))}\n </div>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", minHeight: 0, padding: `${px(22)}px ${px(28)}px 0`}}>\n <div style={{color: INK, fontSize: px(32), fontWeight: 400, letterSpacing: \"-0.01em\"}}>\n {title} <span style={{color: MUTED}}>#{number}</span>\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12), marginTop: px(14)}}>\n <span\n style={{\n alignItems: \"center\",\n background: OPEN,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"inline-flex\",\n fontSize: px(17),\n fontWeight: 600,\n padding: `${px(6)}px ${px(14)}px`,\n }}\n >\n Open\n </span>\n <span style={{color: MUTED, fontSize: px(18)}}>\n <span style={{color: INK}}>{request?.author}</span> wants to merge 3 commits into{\" \"}\n <span style={{background: INSET, borderRadius: px(5), color: INK, fontFamily: brand.typography.mono, padding: `${px(2)}px ${px(7)}px`}}>\n {base}\n </span>{\" \"}\n from{\" \"}\n <span style={{background: INSET, borderRadius: px(5), color: INK, fontFamily: brand.typography.mono, padding: `${px(2)}px ${px(7)}px`}}>\n {branch}\n </span>\n </span>\n </div>\n\n <div style={{borderBottom: `${px(1)}px solid ${EDGE}`, display: \"flex\", gap: px(24), marginTop: px(18)}}>\n {[\n [\"Conversation\", \"2\"],\n [\"Commits\", \"3\"],\n [\"Checks\", \"3\"],\n [\"Files changed\", \"6\"],\n ].map(([tab, count]) => (\n <span\n key={tab}\n style={{\n alignItems: \"center\",\n borderBottom: `${px(2)}px solid ${tab === \"Conversation\" ? ORANGE : \"transparent\"}`,\n color: tab === \"Conversation\" ? INK : MUTED,\n display: \"flex\",\n fontSize: px(18),\n fontWeight: tab === \"Conversation\" ? 600 : 400,\n gap: px(8),\n paddingBottom: px(12),\n }}\n >\n {tab}\n <span style={{background: INSET, borderRadius: px(999), color: MUTED, fontSize: px(15), padding: `${px(1)}px ${px(8)}px`}}>\n {count}\n </span>\n </span>\n ))}\n </div>\n\n <div style={{display: \"flex\", flex: 1, gap: px(28), minHeight: 0, paddingTop: px(6)}}>\n {/* The timeline. */}\n <div style={{flex: 1, minWidth: 0}}>\n {request && posted ? comment({name: request.author, time: request.time}, asked, <span style={{color: INK, fontSize: px(20), lineHeight: 1.55}}>{request.body}</span>) : null}\n\n {frame >= workingAt && agent\n ? comment(\n {name: agent.name, time: agent.time, bot: true},\n answering,\n <div style={{minHeight: px(60)}}>\n {working ? (\n dots\n ) : (\n <span style={{color: INK, fontSize: px(20), lineHeight: 1.55}}>{agent.body}</span>\n )}\n\n {suggestion && landed ? (\n <div\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(8),\n fontFamily: brand.typography.mono,\n fontSize: px(17),\n marginTop: px(14),\n opacity: after,\n overflow: \"hidden\",\n }}\n >\n <div style={{background: INSET, borderBottom: `${px(1)}px solid ${EDGE}`, color: MUTED, padding: `${px(9)}px ${px(14)}px`}}>\n {suggestion.file}\n </div>\n {suggestion.removed ? (\n <div style={{background: \"rgba(248,81,73,0.12)\", color: INK, padding: `${px(7)}px ${px(14)}px`}}>\n <span style={{color: \"#F85149\"}}>- </span>\n {suggestion.removed}\n </div>\n ) : null}\n {suggestion.added ? (\n <div style={{background: \"rgba(63,185,80,0.12)\", color: INK, padding: `${px(7)}px ${px(14)}px`}}>\n <span style={{color: \"#3FB950\"}}>+ </span>\n {suggestion.added}\n </div>\n ) : null}\n </div>\n ) : null}\n </div>,\n resolved && landed ? (\n <div\n style={{\n alignItems: \"center\",\n borderTop: `${px(1)}px solid ${EDGE}`,\n color: MUTED,\n display: \"flex\",\n fontSize: px(18),\n gap: px(10),\n opacity: after,\n padding: `${px(12)}px ${px(18)}px`,\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(20)} height={px(20)}>\n <circle cx=\"12\" cy=\"12\" r=\"10\" fill={BOT} />\n <path d=\"M7.5 12.2l3 3 6-6.4\" fill=\"none\" stroke={CANVAS} strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2.4} />\n </svg>\n {resolved}\n </div>\n ) : undefined,\n )\n : null}\n\n {/* The comment box, where the question is actually written.\n It empties on post, the way the real one does. */}\n <div\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(10),\n marginTop: px(16),\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n background: INSET,\n borderBottom: `${px(1)}px solid ${EDGE}`,\n color: INK,\n fontSize: px(18),\n fontWeight: 600,\n padding: `${px(10)}px ${px(16)}px`,\n }}\n >\n Write\n </div>\n <div style={{color: posted ? MUTED : INK, fontSize: px(20), lineHeight: 1.55, minHeight: px(56), padding: `${px(14)}px ${px(18)}px`}}>\n {posted ? (\n \"Add your comment here...\"\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: INK,\n display: \"inline-block\",\n height: px(19),\n marginLeft: px(2),\n transform: `translateY(${px(3)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </div>\n </div>\n </div>\n\n {/* The sidebar, which names who is expected to look. */}\n <div style={{flex: \"none\", width: px(330)}}>\n {sidebarSection(\n \"Reviewers\",\n <div style={{display: \"grid\", gap: px(10)}}>\n {reviewers.map((name) => (\n <span key={name} style={{alignItems: \"center\", color: INK, display: \"flex\", fontSize: px(18), gap: px(10)}}>\n {avatar(name, null, 26)}\n {name}\n </span>\n ))}\n </div>,\n )}\n {sidebarSection(\n \"Assignees\",\n <span style={{alignItems: \"center\", color: INK, display: \"flex\", fontSize: px(18), gap: px(10)}}>\n {avatar(agent?.name ?? \"odori\", BOT, 26)}\n {agent?.name}\n </span>,\n )}\n {sidebarSection(\n \"Labels\",\n <div style={{display: \"flex\", flexWrap: \"wrap\", gap: px(8)}}>\n {labels.map((label) => (\n <span\n key={label}\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(999),\n color: MUTED,\n fontSize: px(16),\n padding: `${px(4)}px ${px(12)}px`,\n }}\n >\n {label}\n </span>\n ))}\n </div>,\n )}\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
2021
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type GithubCommentProps = {\n /** The repository, as the breadcrumb states it. */\n owner?: string;\n repo?: string;\n /** The pull request. */\n title?: string;\n number?: number;\n /** Branches, for the line under the title. */\n branch?: string;\n base?: string;\n /** The comment that asked for something. */\n request?: {author: string; body: string; time?: string};\n /** Who is answering, and what they say. The body types in. */\n agent?: {name: string; body: string; time?: string};\n /** A suggested change under the reply. */\n suggestion?: {file: string; removed?: string; added?: string};\n /** The check the reply ends on. */\n resolved?: string;\n /** Names in the sidebar's reviewers list. */\n reviewers?: string[];\n labels?: string[];\n thinkingFrames?: number;\n 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",
2022
2022
  "target": "videos/components/github-comment/github-comment.tsx"
2023
2023
  },
2024
2024
  {
2025
2025
  "path": "components/github-comment/github-comment.preview.tsx",
2026
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {GithubComment} from \"./github-comment\";\n\nexport default defineComponentPreview({\n title: \"GitHub comment\",\n category: \"Products\",\n description: \"An agent answering on a pull request, on the page the answer appears on.\",\n component: GithubComment,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Pin the render toolchain\", maxLength: 64},\n owner: {type: \"text\", defaultValue: \"johndoe\", maxLength: 28},\n repo: {type: \"text\", defaultValue: \"odori\", maxLength: 28},\n number: {type: \"number\", defaultValue: 128, min: 1, max: 99999, step: 1},\n resolved: {type: \"text\", defaultValue: \"Resolved conversation\", maxLength: 40},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {\n name: \"With a suggestion\",\n props: {\n suggestion: {\n file: \"packages/odori-cli/src/binaries.ts\",\n removed: 'const chrome = which(\"chrome\");',\n added: 'const chrome = await resolveBrowser(config);',\n },\n },\n },\n ],\n});\n",
2026
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {GithubComment} from \"./github-comment\";\n\nexport default defineComponentPreview({\n title: \"GitHub comment\",\n category: \"Products\",\n description: \"An agent answering on a pull request, on the page the answer appears on.\",\n component: GithubComment,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Pin the render toolchain\", maxLength: 64},\n owner: {type: \"text\", defaultValue: \"johndoe\", maxLength: 28},\n repo: {type: \"text\", defaultValue: \"odori\", maxLength: 28},\n number: {type: \"number\", defaultValue: 128, min: 1, max: 99999, step: 1},\n resolved: {type: \"text\", defaultValue: \"Resolved conversation\", maxLength: 40},\n theme: {type: \"select\", defaultValue: \"light\", options: [\"light\", \"dimmed\"]},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {\n name: \"With a suggestion\",\n props: {\n suggestion: {\n file: \"packages/odori-cli/src/binaries.ts\",\n removed: 'const chrome = which(\"chrome\");',\n added: 'const chrome = await resolveBrowser(config);',\n },\n },\n },\n ],\n});\n",
2027
2027
  "target": "videos/components/github-comment/github-comment.preview.tsx"
2028
2028
  }
2029
2029
  ],
@@ -2065,12 +2065,12 @@
2065
2065
  "files": [
2066
2066
  {
2067
2067
  "path": "components/github-pr/github-pr.tsx",
2068
- "content": "import {Easing, Fill, interpolate, spring, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\nexport type GithubCheck = {name: string; detail?: string};\n\nexport type GithubPrProps = {\n /** The pull request title. */\n title: string;\n /** The number beside it. */\n number?: number;\n /** Who opened it. */\n author?: string;\n /** The branch it merges from. */\n branch?: string;\n /** The branch it merges into. */\n base?: string;\n /** Files and lines, as the header states them. */\n stats?: {files: number; additions: number; deletions: number};\n /** Checks that resolve one after another, then unlock the merge. */\n checks?: GithubCheck[];\n theme?: \"dark\" | \"light\";\n};\n\nconst PALETTE = {\n dark: {page: \"#212830\", card: \"#262C36\", edge: \"#3D444D\", ink: \"#D1D7E0\", faint: \"#9198A1\", open: \"#347D39\"},\n light: {page: \"#FFFFFF\", card: \"#F6F8FA\", edge: \"#D1D9E0\", ink: \"#1F2328\", faint: \"#59636E\", open: \"#1F883D\"},\n};\n\nconst FONT = '\"Mona Sans VF\", \"Mona Sans\", -apple-system, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif';\n\nconst CHECK_START = 34;\nconst CHECK_STEP = 14;\n\n/**\n * GitHub's default avatar is an identicon, not a letter.\n *\n * A five by five grid, mirrored down the middle, inset by a margin the way\n * GitHub insets it, with the filled cells and the colour both derived from a\n * hash of the login. GitHub keys it off the account id; the login stands in\n * here, and the only property that matters is the one GitHub relies on: the\n * same name always draws the same mark.\n */\nconst identicon = (name: string, size: number, px: (value: number) => number) => {\n /* FNV-1a, then a mix, because a plain multiply-and-add leaves the low bits\n barely changed between similar logins and every mark comes out alike. */\n let hash = 0x811c9dc5;\n for (const character of name) {\n hash = (hash ^ character.codePointAt(0)!) >>> 0;\n hash = Math.imul(hash, 0x01000193) >>> 0;\n }\n hash = (hash ^ (hash >>> 15)) >>> 0;\n hash = Math.imul(hash, 0x2545f491) >>> 0;\n hash = (hash ^ (hash >>> 13)) >>> 0;\n\n const ink = `hsl(${hash % 360} 58% 50%)`;\n const margin = size / 12;\n const cell = (size - margin * 2) / 5;\n const cells = [];\n for (let column = 0; column < 3; column += 1) {\n for (let row = 0; row < 5; row += 1) {\n /* One bit per cell, taken from a different part of the word each time\n so the three columns do not repeat one another. */\n const bit = (Math.imul(hash, column * 5 + row + 1) >>> ((column * 5 + row) % 13)) & 1;\n if (!bit) continue;\n for (const x of column === 2 ? [2] : [column, 4 - column]) {\n cells.push(\n <rect\n key={`${x}-${row}`}\n x={margin + x * cell}\n y={margin + row * cell}\n width={cell}\n height={cell}\n fill={ink}\n />,\n );\n }\n }\n }\n\n return (\n <span style={{borderRadius: px(999), flex: \"none\", lineHeight: 0, overflow: \"hidden\"}}>\n <svg width={px(size)} height={px(size)} viewBox={`0 0 ${size} ${size}`}>\n <rect width={size} height={size} fill=\"#F0F0F0\" />\n {cells}\n </svg>\n </span>\n );\n};\n\n/**\n * A pull request with its checks going green and the merge button unlocking.\n *\n * Checks resolve in sequence rather than together, and the merge button only\n * fills once the last one lands. That order is the whole story: a video that\n * greens everything at once shows a result, and a video that greens them one\n * at a time shows a system working.\n */\nexport const GithubPr = ({\n title,\n number = 128,\n author = \"johndoe\",\n branch = \"feat/agent-surfaces\",\n base = \"main\",\n stats = {files: 6, additions: 214, deletions: 38},\n checks = [\n {name: \"typecheck\", detail: \"12s\"},\n {name: \"test\", detail: \"48s\"},\n {name: \"build\", detail: \"1m 04s\"},\n ],\n theme = \"dark\",\n}: GithubPrProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const settled = CHECK_START + Math.max(0, checks.length - 1) * CHECK_STEP + 12;\n const merge = spring({frame, fps, delayInFrames: settled, stiffness: 160, damping: 16});\n\n return (\n <Fill style={{alignItems: \"center\", background: colors.page, justifyContent: \"center\", padding: px(110)}}>\n <div\n style={{\n fontFamily: FONT,\n maxWidth: px(1280),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(16)}px)`,\n width: \"100%\",\n }}\n >\n <div style={{color: colors.ink, fontSize: px(44), fontWeight: 600, letterSpacing: \"-0.02em\"}}>\n {title} <span style={{color: colors.faint, fontWeight: 400}}>#{number}</span>\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(14), marginTop: px(18)}}>\n <span\n style={{\n alignItems: \"center\",\n background: colors.open,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"inline-flex\",\n fontSize: px(21),\n fontWeight: 600,\n gap: px(8),\n padding: `${px(8)}px ${px(18)}px`,\n }}\n >\n Open\n </span>\n {/* GitHub puts the author's picture in the byline, and for an\n account without one that picture is an identicon. */}\n {identicon(author, 24, px)}\n <span style={{color: colors.faint, fontSize: px(22)}}>\n <span style={{color: colors.ink, fontWeight: 600}}>{author}</span> wants to merge into{\" \"}\n <span style={{color: colors.ink, fontFamily: brand.typography.mono}}>{base}</span> from{\" \"}\n <span style={{color: colors.ink, fontFamily: brand.typography.mono}}>{branch}</span>\n </span>\n </div>\n\n <div\n style={{\n background: colors.card,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(12),\n marginTop: px(30),\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n color: colors.faint,\n display: \"flex\",\n fontFamily: brand.typography.mono,\n fontSize: px(21),\n gap: px(22),\n padding: `${px(18)}px ${px(24)}px`,\n }}\n >\n <span>{stats.files} files</span>\n <span style={{color: \"#3FB950\"}}>+{stats.additions}</span>\n <span style={{color: \"#F85149\"}}>-{stats.deletions}</span>\n </div>\n\n <div style={{display: \"grid\"}}>\n {checks.map((check, index) => {\n // Each check resolves on its own beat, and the tick springs in\n // where the spinner was.\n const done = spring({frame, fps, delayInFrames: CHECK_START + index * CHECK_STEP, stiffness: 200, damping: 15});\n return (\n <div\n key={check.name}\n style={{\n alignItems: \"center\",\n borderTop: index === 0 ? \"none\" : `${px(1)}px solid ${colors.edge}`,\n display: \"flex\",\n gap: px(16),\n padding: `${px(18)}px ${px(24)}px`,\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n display: \"inline-flex\",\n height: px(28),\n justifyContent: \"center\",\n position: \"relative\",\n width: px(28),\n }}\n >\n <span\n style={{\n border: `${px(3)}px solid ${colors.faint}`,\n borderRadius: px(999),\n borderTopColor: \"transparent\",\n height: px(22),\n opacity: 1 - done,\n position: \"absolute\",\n transform: `rotate(${frame * 12}deg)`,\n width: px(22),\n }}\n />\n <svg\n viewBox=\"0 0 24 24\"\n width={px(26)}\n height={px(26)}\n style={{opacity: done, position: \"absolute\", transform: `scale(${0.6 + done * 0.4})`}}\n >\n <circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#3FB950\" />\n <path\n d=\"M7.5 12.2l3 3 6-6.4\"\n fill=\"none\"\n stroke=\"#0D1117\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.4}\n />\n </svg>\n </span>\n <span style={{color: colors.ink, fontSize: px(24), fontWeight: 500}}>{check.name}</span>\n {check.detail ? (\n <span style={{color: colors.faint, fontSize: px(21), marginLeft: \"auto\"}}>{check.detail}</span>\n ) : null}\n </div>\n );\n })}\n </div>\n\n <div\n style={{\n alignItems: \"center\",\n borderTop: `${px(1)}px solid ${colors.edge}`,\n display: \"flex\",\n gap: px(18),\n padding: `${px(22)}px ${px(24)}px`,\n }}\n >\n <span\n style={{\n background: `color-mix(in srgb, ${colors.open} ${merge * 100}%, ${colors.edge})`,\n borderRadius: px(8),\n color: `color-mix(in srgb, #FFFFFF ${40 + merge * 60}%, ${colors.faint})`,\n fontSize: px(24),\n fontWeight: 600,\n padding: `${px(14)}px ${px(28)}px`,\n transform: `scale(${0.98 + merge * 0.02})`,\n }}\n >\n Merge pull request\n </span>\n <span style={{color: colors.faint, fontSize: px(21), opacity: merge}}>All checks have passed</span>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
2068
+ "content": "import {Easing, Fill, interpolate, spring, useBrand, useDesignScale, useFrame, useVideo} from \"odori\";\n\nexport type GithubCheck = {name: string; detail?: string};\n\nexport type GithubPrProps = {\n /** The pull request title. */\n title: string;\n /** The number beside it. */\n number?: number;\n /** Who opened it. */\n author?: string;\n /** The branch it merges from. */\n branch?: string;\n /** The branch it merges into. */\n base?: string;\n /** Files and lines, as the header states them. */\n stats?: {files: number; additions: number; deletions: number};\n /** Checks that resolve one after another, then unlock the merge. */\n checks?: GithubCheck[];\n theme?: \"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",
2069
2069
  "target": "videos/components/github-pr/github-pr.tsx"
2070
2070
  },
2071
2071
  {
2072
2072
  "path": "components/github-pr/github-pr.preview.tsx",
2073
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {GithubPr} from \"./github-pr\";\n\nexport default defineComponentPreview({\n title: \"GitHub pull request\",\n category: \"Products\",\n description: \"A pull request whose checks resolve one at a time and unlock the merge.\",\n component: GithubPr,\n canvas: {width: 1920, height: 1080, duration: \"7s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Add agent and product surfaces\", maxLength: 72},\n number: {type: \"number\", defaultValue: 128, min: 1, max: 99999, step: 1},\n author: {type: \"text\", defaultValue: \"johndoe\", maxLength: 28},\n branch: {type: \"text\", defaultValue: \"feat/agent-surfaces\", maxLength: 40},\n base: {type: \"text\", defaultValue: \"main\", maxLength: 24},\n theme: {type: \"select\", defaultValue: \"dark\", options: [\"dark\", \"light\"]},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Light\", props: {theme: \"light\"}},\n {\n name: \"One check\",\n props: {title: \"Pin the render toolchain\", checks: [{name: \"e2e\", detail: \"3m 12s\"}]},\n },\n ],\n});\n",
2073
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {GithubPr} from \"./github-pr\";\n\nexport default defineComponentPreview({\n title: \"GitHub pull request\",\n category: \"Products\",\n description: \"A pull request whose checks resolve one at a time and unlock the merge.\",\n component: GithubPr,\n canvas: {width: 1920, height: 1080, duration: \"7s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Add agent and product surfaces\", maxLength: 72},\n number: {type: \"number\", defaultValue: 128, min: 1, max: 99999, step: 1},\n author: {type: \"text\", defaultValue: \"johndoe\", maxLength: 28},\n branch: {type: \"text\", defaultValue: \"feat/agent-surfaces\", maxLength: 40},\n base: {type: \"text\", defaultValue: \"main\", maxLength: 24},\n theme: {type: \"select\", defaultValue: \"light\", options: [\"light\", \"dimmed\"]},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Dimmed\", props: {theme: \"dimmed\"}},\n {\n name: \"One check\",\n props: {title: \"Pin the render toolchain\", checks: [{name: \"e2e\", detail: \"3m 12s\"}]},\n },\n ],\n});\n",
2074
2074
  "target": "videos/components/github-pr/github-pr.preview.tsx"
2075
2075
  }
2076
2076
  ],
@@ -2411,7 +2411,7 @@
2411
2411
  "files": [
2412
2412
  {
2413
2413
  "path": "components/imessage/imessage.tsx",
2414
- "content": "import {Easing, Fill, interpolate, spring, typingFrames, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type IMessageBubble = {\n /** \"you\" is the blue bubble on the right, \"them\" the grey one on the left. */\n from: \"them\" | \"you\";\n body: string;\n};\n\nexport type IMessageConversation = {\n name: string;\n preview: string;\n time?: string;\n};\n\nexport type IMessageProps = {\n /** The conversation list down the left. The first one is the open thread. */\n conversations?: IMessageConversation[];\n /** Who the open thread is with. */\n contact?: string;\n /** Bubbles already in the thread. */\n bubbles?: IMessageBubble[];\n /** The reply, after a typing bubble. */\n reply?: IMessageBubble;\n /** Frames the typing bubble holds. */\n thinkingFrames?: number;\n /** The receipt under the last bubble. */\n receipt?: string;\n /** The stamp Messages centres in the thread when a conversation resumes. */\n stamp?: string;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\n/**\n * Apple's system palette rather than an approximation of it: the greys are\n * systemGray4 through systemGray6, and the blue is systemBlue, which shifts\n * a step brighter in the dark so it holds the same weight against black.\n */\nconst PALETTE = {\n dark: {\n page: \"#000000\",\n list: \"#1C1C1E\",\n edge: \"#38383A\",\n them: \"#3B3B3D\",\n ink: \"#FFFFFF\",\n faint: \"#8E8E93\",\n field: \"#1C1C1E\",\n blue: \"#0A84FF\",\n },\n light: {\n page: \"#FFFFFF\",\n list: \"#F6F6F6\",\n edge: \"#D1D1D6\",\n them: \"#E9E9EB\",\n ink: \"#000000\",\n faint: \"#8E8E93\",\n field: \"#FFFFFF\",\n blue: \"#007AFF\",\n },\n};\n/** Messages sets its interface in the system face, SF Pro on Apple hardware. */\nconst FONT = '-apple-system, \"SF Pro Text\", \"SF Pro\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\n\n/**\n * Messages draws a contact with no photo as initials on a grey, never on a\n * tinted circle. Two initials where there are two names, one otherwise.\n */\nconst CONTACT_GREY = \"linear-gradient(#A5A5AA, #8A8A8F)\";\n\nconst initials = (name: string) => {\n const words = name.trim().split(/\\s+/).filter(Boolean);\n if (words.length === 0) return \"\";\n if (words.length === 1) return words[0].slice(0, 1).toUpperCase();\n return (words[0][0] + words[words.length - 1][0]).toUpperCase();\n};\n\n/**\n * Messages, with the conversation list beside the thread.\n *\n * The list is what makes this a phone rather than a chat widget: it says this\n * is one thread among many, that the agent sits in the same place as the\n * people you text, and it gives the frame somewhere for the eye to rest while\n * the thread does the moving. The typing bubble is still the point, and the\n * reply grows out of exactly where the dots were.\n */\nexport const IMessage = ({\n conversations = [\n {name: \"Odori\", preview: \"Done. out/launch-30s.mp4, same brand.\", time: \"now\"},\n {name: \"John Doe\", preview: \"sounds good, ship it\", time: \"9:12 AM\"},\n {name: \"Design\", preview: \"Dan: new mark is in Figma\", time: \"Yesterday\"},\n ],\n contact = \"Odori\",\n bubbles = [{from: \"you\", body: \"can you cut a 30 second version of the launch video?\"}],\n reply = {from: \"them\", body: \"Done. out/launch-30s.mp4, same brand, ends on the mark.\"},\n thinkingFrames = 34,\n receipt = \"Delivered\",\n stamp = \"Today 11:31 AM\",\n theme = \"dark\",\n charactersPerSecond = 32,\n}: IMessageProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n\n const scale = useDesignScale();\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n /* The typing belongs to the person holding the phone. They compose the\n last bubble in the field and send it; the reply arrives whole, the way\n a message from someone else does. */\n const last = bubbles[bubbles.length - 1];\n const earlier = bubbles.slice(0, -1);\n const first = 18;\n const step = 18;\n const typeFrom = first + earlier.length * step;\n const typed = useTyping(last?.body ?? \"\", {from: typeFrom, charactersPerSecond, chunk: 2});\n const sendAt = typeFrom + typingFrames(last?.body ?? \"\", {charactersPerSecond}) + 10;\n const typingAt = sendAt + 12;\n const replyAt = typingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const landed = spring({frame, fps, delayInFrames: replyAt, stiffness: 180, damping: 15});\n const sent = frame >= sendAt;\n const typing = frame >= typingAt && frame < replyAt;\n\n /** The small circle Messages puts beside a bubble that is not yours. */\n const contactAvatar = (name: string, size: number) => (\n <span\n style={{\n alignItems: \"center\",\n background: CONTACT_GREY,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(size * 0.38),\n height: px(size),\n justifyContent: \"center\",\n marginBottom: px(2),\n width: px(size),\n }}\n >\n {initials(name)}\n </span>\n );\n\n /**\n * A bubble, with the tail Messages draws on it.\n *\n * The tail is two shapes rather than a corner radius: one in the bubble's\n * own colour reaching past its edge, and one in the page colour carving\n * that back into a hook. A small radius on the corner reads as a chamfer\n * and never as a tail, which is what the earlier version did.\n */\n const bubble = (\n from: \"them\" | \"you\",\n grown: number,\n body: ReactNode,\n key?: string,\n tight = false,\n ) => {\n const mine = from === \"you\";\n const skin = mine ? colors.blue : colors.them;\n const side = mine ? \"right\" : \"left\";\n\n return (\n <div\n key={key}\n style={{\n alignItems: \"flex-end\",\n display: \"flex\",\n gap: px(20),\n justifyContent: mine ? \"flex-end\" : \"flex-start\",\n opacity: grown,\n padding: `${px(3)}px ${px(26)}px`,\n }}\n >\n {mine ? null : contactAvatar(contact, 34)}\n <div\n style={{\n background: skin,\n borderBottomLeftRadius: mine ? px(26) : px(11),\n borderBottomRightRadius: mine ? px(11) : px(26),\n borderRadius: px(26),\n color: mine ? \"#FFFFFF\" : colors.ink,\n fontSize: px(23),\n lineHeight: 1.29,\n maxWidth: \"68%\",\n padding: tight ? `${px(13)}px ${px(18)}px` : `${px(14)}px ${px(20)}px`,\n position: \"relative\",\n transform: `scale(${0.8 + grown * 0.2})`,\n transformOrigin: mine ? \"100% 100%\" : \"0% 100%\",\n }}\n >\n {body}\n {/* The tail, as one filled path rather than a coloured rectangle\n carved by a second one in the page colour. The carve version\n paints over whatever sits beside the bubble, which here is the\n contact's picture. */}\n <svg\n viewBox=\"0 0 16 22\"\n width={px(21)}\n height={px(29)}\n style={{bottom: 0, position: \"absolute\", [side]: px(-16)}}\n >\n <path\n d={mine ? \"M16 22C7 22 0 15.6 0 6.5L0 22Z\" : \"M0 22C9 22 16 15.6 16 6.5L16 22Z\"}\n fill={skin}\n />\n </svg>\n </div>\n </div>\n );\n };\n\n return (\n <Fill style={{background: \"#000000\", padding: px(60)}}>\n <div\n style={{\n borderRadius: px(16),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The conversation list. */}\n <div style={{background: colors.list, borderRight: `${px(1)}px solid ${colors.edge}`, flex: \"none\", width: px(400)}}>\n <div style={{color: colors.ink, fontSize: px(30), fontWeight: 700, letterSpacing: \"-0.02em\", padding: `${px(24)}px ${px(22)}px ${px(14)}px`}}>\n Messages\n </div>\n <div\n style={{\n background: theme === \"dark\" ? \"#2C2C2E\" : \"#E9E9EB\",\n borderRadius: px(10),\n color: colors.faint,\n fontSize: px(19),\n margin: `0 ${px(18)}px ${px(14)}px`,\n padding: `${px(10)}px ${px(14)}px`,\n }}\n >\n Search\n </div>\n {conversations.map((item, index) => (\n <div\n key={item.name}\n style={{\n background: index === 0 ? (theme === \"dark\" ? \"#2C2C2E\" : \"#DCDCE0\") : \"transparent\",\n borderRadius: px(10),\n display: \"flex\",\n gap: px(14),\n margin: `${px(2)}px ${px(12)}px`,\n padding: `${px(14)}px ${px(12)}px`,\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: CONTACT_GREY,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(20),\n height: px(50),\n justifyContent: \"center\",\n width: px(50),\n }}\n >\n {initials(item.name)}\n </span>\n <div style={{minWidth: 0, width: \"100%\"}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(10)}}>\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{item.name}</span>\n <span style={{color: colors.faint, fontSize: px(16), marginLeft: \"auto\"}}>{item.time}</span>\n </div>\n <div\n style={{\n color: colors.faint,\n fontSize: px(18),\n marginTop: px(3),\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.preview}\n </div>\n </div>\n </div>\n ))}\n </div>\n\n {/* The thread. */}\n <div style={{background: colors.page, display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div\n style={{\n alignItems: \"center\",\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n display: \"flex\",\n flexDirection: \"column\",\n gap: px(6),\n padding: `${px(16)}px ${px(24)}px`,\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: CONTACT_GREY,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(19),\n height: px(48),\n justifyContent: \"center\",\n width: px(48),\n }}\n >\n {initials(contact)}\n </span>\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600, letterSpacing: \"-0.01em\"}}>{contact}</span>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", justifyContent: \"flex-end\", paddingBottom: px(10)}}>\n {/* Messages centres a stamp in the thread rather than putting a\n time on each bubble, and the day is the part it emphasises. */}\n {stamp ? (\n <div\n style={{\n color: colors.faint,\n fontSize: px(17),\n padding: `${px(10)}px 0 ${px(14)}px`,\n textAlign: \"center\",\n }}\n >\n <span style={{color: colors.ink, fontWeight: 600}}>{stamp.split(\" \")[0]}</span>{\" \"}\n {stamp.split(\" \").slice(1).join(\" \")}\n </div>\n ) : null}\n {earlier.map((item, index) =>\n bubble(\n item.from,\n spring({frame, fps, delayInFrames: first + index * step, stiffness: 180, damping: 15}),\n item.body,\n `${item.from}-${index}`,\n ),\n )}\n {last && sent\n ? bubble(\n last.from,\n spring({frame, fps, delayInFrames: sendAt, stiffness: 180, damping: 15}),\n last.body,\n \"sent\",\n )\n : null}\n\n {typing\n ? bubble(\n reply.from,\n spring({frame, fps, delayInFrames: typingAt, stiffness: 200, damping: 14}),\n <span style={{alignItems: \"center\", display: \"flex\", gap: px(9), height: px(15)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: colors.faint,\n borderRadius: px(999),\n height: px(15),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(15),\n }}\n />\n ))}\n </span>,\n \"typing\",\n true,\n )\n : null}\n\n {frame >= replyAt\n ? bubble(\n reply.from,\n landed,\n reply.body,\n )\n : null}\n\n {receipt && frame >= replyAt ? (\n <div style={{color: colors.faint, fontSize: px(16), padding: `${px(4)}px ${px(30)}px 0`, textAlign: \"right\"}}>\n {receipt}\n </div>\n ) : null}\n </div>\n\n {/* The composer, with what Messages puts around the field: the\n apps button on the left, and a send arrow that only appears\n once there is something to send. */}\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12), margin: `0 ${px(24)}px ${px(20)}px`}}>\n <span\n style={{\n alignItems: \"center\",\n background: colors.field,\n borderRadius: px(999),\n color: colors.faint,\n display: \"flex\",\n flex: \"none\",\n fontSize: px(26),\n height: px(38),\n justifyContent: \"center\",\n width: px(38),\n }}\n >\n +\n </span>\n <div\n style={{\n alignItems: \"center\",\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(999),\n color: sent ? colors.faint : colors.ink,\n display: \"flex\",\n flex: 1,\n fontSize: px(20),\n gap: px(10),\n minWidth: 0,\n padding: `${px(9)}px ${px(9)}px ${px(9)}px ${px(20)}px`,\n }}\n >\n <span style={{flex: 1, minWidth: 0}}>\n {sent ? (\n \"iMessage\"\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(22),\n marginLeft: px(2),\n transform: `translateY(${px(4)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </span>\n {sent ? (\n /* An empty field carries the audio and emoji controls; the\n send arrow replaces them only once there is something to\n send, which is the swap Messages does. */\n <span style={{alignItems: \"center\", color: colors.faint, display: \"flex\", flex: \"none\", gap: px(12)}}>\n <svg viewBox=\"0 0 24 24\" width={px(24)} height={px(24)} stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\">\n <path d=\"M4 11v2M8 8v8M12 5v14M16 8v8M20 11v2\" />\n </svg>\n <svg viewBox=\"0 0 24 24\" width={px(26)} height={px(26)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.7}>\n <circle cx=\"12\" cy=\"12\" r=\"9.2\" />\n <circle cx=\"9\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <circle cx=\"15\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <path d=\"M8.4 14.2a4.6 4.6 0 0 0 7.2 0\" strokeLinecap=\"round\" />\n </svg>\n </span>\n ) : (\n <span\n style={{\n alignItems: \"center\",\n background: colors.blue,\n borderRadius: px(999),\n display: \"flex\",\n flex: \"none\",\n height: px(30),\n justifyContent: \"center\",\n width: px(30),\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(18)} height={px(18)} fill=\"none\" stroke=\"#FFFFFF\" strokeWidth={2.6}>\n <path d=\"M12 19V6M6 12l6-6 6 6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </span>\n )}\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
2414
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type IMessageBubble = {\n /** \"you\" is the blue bubble on the right, \"them\" the grey one on the left. */\n from: \"them\" | \"you\";\n body: string;\n};\n\nexport type IMessageConversation = {\n name: string;\n preview: string;\n time?: string;\n};\n\nexport type IMessageProps = {\n /** The conversation list down the left. The first one is the open thread. */\n conversations?: IMessageConversation[];\n /** Who the open thread is with. */\n contact?: string;\n /** Bubbles already in the thread. */\n bubbles?: IMessageBubble[];\n /** The reply, after a typing bubble. */\n reply?: IMessageBubble;\n /** Frames the typing bubble holds. */\n thinkingFrames?: number;\n /** The receipt under the last bubble. */\n receipt?: string;\n /** The stamp Messages centres in the thread when a conversation resumes. */\n stamp?: string;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\n/**\n * Apple's system palette rather than an approximation of it: the greys are\n * systemGray4 through systemGray6, and the blue is systemBlue, which shifts\n * a step brighter in the dark so it holds the same weight against black.\n */\nconst PALETTE = {\n dark: {\n page: \"#000000\",\n list: \"#1C1C1E\",\n edge: \"#38383A\",\n them: \"#3B3B3D\",\n ink: \"#FFFFFF\",\n faint: \"#8E8E93\",\n field: \"#1C1C1E\",\n blue: \"#0A84FF\",\n },\n light: {\n page: \"#FFFFFF\",\n list: \"#F6F6F6\",\n edge: \"#D1D1D6\",\n them: \"#E9E9EB\",\n ink: \"#000000\",\n faint: \"#8E8E93\",\n field: \"#FFFFFF\",\n blue: \"#007AFF\",\n },\n};\n/** Messages sets its interface in the system face, SF Pro on Apple hardware. */\nconst FONT = '-apple-system, \"SF Pro Text\", \"SF Pro\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\n\n/**\n * Messages draws a contact with no photo as initials on a grey, never on a\n * tinted circle. Two initials where there are two names, one otherwise.\n */\nconst CONTACT_GREY = \"linear-gradient(#A5A5AA, #8A8A8F)\";\n\nconst initials = (name: string) => {\n const words = name.trim().split(/\\s+/).filter(Boolean);\n if (words.length === 0) return \"\";\n if (words.length === 1) return words[0].slice(0, 1).toUpperCase();\n return (words[0][0] + words[words.length - 1][0]).toUpperCase();\n};\n\n/**\n * Messages, with the conversation list beside the thread.\n *\n * The list is what makes this a phone rather than a chat widget: it says this\n * is one thread among many, that the agent sits in the same place as the\n * people you text, and it gives the frame somewhere for the eye to rest while\n * the thread does the moving. The typing bubble is still the point, and the\n * reply grows out of exactly where the dots were.\n */\nexport const IMessage = ({\n conversations = [\n {name: \"Odori\", preview: \"Done. out/launch-30s.mp4, same brand.\", time: \"now\"},\n {name: \"John Doe\", preview: \"sounds good, ship it\", time: \"9:12 AM\"},\n {name: \"Design\", preview: \"Dan: new mark is in Figma\", time: \"Yesterday\"},\n ],\n contact = \"Odori\",\n bubbles = [{from: \"you\", body: \"can you cut a 30 second version of the launch video?\"}],\n reply = {from: \"them\", body: \"Done. out/launch-30s.mp4, same brand, ends on the mark.\"},\n thinkingFrames = 34,\n receipt = \"Delivered\",\n stamp = \"Today 11:31 AM\",\n theme = \"dark\",\n charactersPerSecond = 32,\n}: IMessageProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n\n const scale = useDesignScale();\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n /* The typing belongs to the person holding the phone. They compose the\n last bubble in the field and send it; the reply arrives whole, the way\n a message from someone else does. */\n const last = bubbles[bubbles.length - 1];\n const earlier = bubbles.slice(0, -1);\n const first = 18;\n const step = 18;\n const typeFrom = first + earlier.length * step;\n const typed = useTyping(last?.body ?? \"\", {from: typeFrom, charactersPerSecond, chunk: 2});\n const sendAt = typeFrom + typingFrames(last?.body ?? \"\", {charactersPerSecond}) + 10;\n const typingAt = sendAt + 12;\n const replyAt = typingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const landed = spring({frame, fps, delayInFrames: replyAt, stiffness: 180, damping: 15});\n const sent = frame >= sendAt;\n const typing = frame >= typingAt && frame < replyAt;\n\n /**\n * A bubble, with the tail Messages draws on it.\n *\n * The tail is two shapes rather than a corner radius: one in the bubble's\n * own colour reaching past its edge, and one in the page colour carving\n * that back into a hook. A small radius on the corner reads as a chamfer\n * and never as a tail, which is what the earlier version did.\n */\n const bubble = (\n from: \"them\" | \"you\",\n grown: number,\n body: ReactNode,\n key?: string,\n tight = false,\n ) => {\n const mine = from === \"you\";\n const skin = mine ? colors.blue : colors.them;\n const side = mine ? \"right\" : \"left\";\n\n return (\n <div\n key={key}\n style={{\n alignItems: \"flex-end\",\n display: \"flex\",\n justifyContent: mine ? \"flex-end\" : \"flex-start\",\n opacity: grown,\n padding: `${px(3)}px ${px(26)}px`,\n }}\n >\n <div\n style={{\n background: skin,\n borderBottomLeftRadius: mine ? px(26) : px(5),\n borderBottomRightRadius: mine ? px(5) : px(26),\n borderRadius: px(26),\n color: mine ? \"#FFFFFF\" : colors.ink,\n fontSize: px(23),\n lineHeight: 1.29,\n maxWidth: \"74%\",\n padding: tight ? `${px(14)}px ${px(16)}px` : `${px(14)}px ${px(20)}px`,\n position: \"relative\",\n transform: `scale(${0.8 + grown * 0.2})`,\n transformOrigin: mine ? \"100% 100%\" : \"0% 100%\",\n }}\n >\n {body}\n {/* The tail, as one filled path rather than a coloured rectangle\n carved by a second one in the page colour. The carve version\n paints over whatever sits beside the bubble, which here is the\n contact's picture. */}\n <svg\n viewBox=\"0 0 16 22\"\n width={px(21)}\n height={px(29)}\n style={{bottom: 0, position: \"absolute\", [side]: px(-11)}}\n >\n <path\n d={mine ? \"M16 22C7 22 0 15.6 0 6.5L0 22Z\" : \"M0 22C9 22 16 15.6 16 6.5L16 22Z\"}\n fill={skin}\n />\n </svg>\n </div>\n </div>\n );\n };\n\n return (\n <Fill style={{background: theme === \"light\" ? \"#DCDCDC\" : \"#000000\", padding: px(60)}}>\n <div\n style={{\n borderRadius: px(16),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The conversation list. */}\n <div style={{background: colors.list, borderRight: `${px(1)}px solid ${colors.edge}`, flex: \"none\", width: px(400)}}>\n <div style={{color: colors.ink, fontSize: px(30), fontWeight: 700, letterSpacing: \"-0.02em\", padding: `${px(24)}px ${px(22)}px ${px(14)}px`}}>\n Messages\n </div>\n <div\n style={{\n background: theme === \"dark\" ? \"#2C2C2E\" : \"#E9E9EB\",\n borderRadius: px(10),\n color: colors.faint,\n fontSize: px(19),\n margin: `0 ${px(18)}px ${px(14)}px`,\n padding: `${px(10)}px ${px(14)}px`,\n }}\n >\n Search\n </div>\n {conversations.map((item, index) => (\n <div\n key={item.name}\n style={{\n background: index === 0 ? (theme === \"dark\" ? \"#2C2C2E\" : \"#DCDCE0\") : \"transparent\",\n borderRadius: px(10),\n display: \"flex\",\n gap: px(14),\n margin: `${px(2)}px ${px(12)}px`,\n padding: `${px(14)}px ${px(12)}px`,\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: CONTACT_GREY,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(20),\n height: px(50),\n justifyContent: \"center\",\n width: px(50),\n }}\n >\n {initials(item.name)}\n </span>\n <div style={{minWidth: 0, width: \"100%\"}}>\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(10)}}>\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{item.name}</span>\n <span style={{color: colors.faint, fontSize: px(16), marginLeft: \"auto\"}}>{item.time}</span>\n </div>\n <div\n style={{\n color: colors.faint,\n fontSize: px(18),\n marginTop: px(3),\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.preview}\n </div>\n </div>\n </div>\n ))}\n </div>\n\n {/* The thread. */}\n <div style={{background: colors.page, display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div\n style={{\n alignItems: \"center\",\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n display: \"flex\",\n flexDirection: \"column\",\n gap: px(6),\n padding: `${px(16)}px ${px(24)}px`,\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: CONTACT_GREY,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(19),\n height: px(48),\n justifyContent: \"center\",\n width: px(48),\n }}\n >\n {initials(contact)}\n </span>\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600, letterSpacing: \"-0.01em\"}}>{contact}</span>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", justifyContent: \"flex-end\", paddingBottom: px(10)}}>\n {/* Messages centres a stamp in the thread rather than putting a\n time on each bubble, and the day is the part it emphasises. */}\n {stamp ? (\n <div\n style={{\n color: colors.faint,\n fontSize: px(17),\n padding: `${px(10)}px 0 ${px(14)}px`,\n textAlign: \"center\",\n }}\n >\n <span style={{color: colors.ink, fontWeight: 600}}>{stamp.split(\" \")[0]}</span>{\" \"}\n {stamp.split(\" \").slice(1).join(\" \")}\n </div>\n ) : null}\n {earlier.map((item, index) =>\n bubble(\n item.from,\n spring({frame, fps, delayInFrames: first + index * step, stiffness: 180, damping: 15}),\n item.body,\n `${item.from}-${index}`,\n ),\n )}\n {last && sent\n ? bubble(\n last.from,\n spring({frame, fps, delayInFrames: sendAt, stiffness: 180, damping: 15}),\n last.body,\n \"sent\",\n )\n : null}\n\n {typing\n ? bubble(\n reply.from,\n spring({frame, fps, delayInFrames: typingAt, stiffness: 200, damping: 14}),\n <span style={{alignItems: \"center\", display: \"flex\", gap: px(7), height: px(11)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: colors.faint,\n borderRadius: px(999),\n height: px(11),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(11),\n }}\n />\n ))}\n </span>,\n \"typing\",\n true,\n )\n : null}\n\n {frame >= replyAt\n ? bubble(\n reply.from,\n landed,\n reply.body,\n )\n : null}\n\n {receipt && frame >= replyAt ? (\n <div style={{color: colors.faint, fontSize: px(16), padding: `${px(4)}px ${px(30)}px 0`, textAlign: \"right\"}}>\n {receipt}\n </div>\n ) : null}\n </div>\n\n {/* The composer, with what Messages puts around the field: the\n apps button on the left, and a send arrow that only appears\n once there is something to send. */}\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12), margin: `0 ${px(24)}px ${px(20)}px`}}>\n <span\n style={{\n alignItems: \"center\",\n background: colors.field,\n borderRadius: px(999),\n color: colors.faint,\n display: \"flex\",\n flex: \"none\",\n height: px(38),\n justifyContent: \"center\",\n width: px(38),\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(22)} height={px(22)} fill=\"currentColor\">\n <path d=\"M10.75 3.25a.75.75 0 0 0-1.5 0v6h-6a.75.75 0 0 0 0 1.5h6v6a.75.75 0 0 0 1.5 0v-6h6a.75.75 0 0 0 0-1.5h-6z\" />\n </svg>\n </span>\n <div\n style={{\n alignItems: \"center\",\n color: sent ? colors.faint : colors.ink,\n display: \"flex\",\n flex: 1,\n fontSize: px(20),\n gap: px(10),\n minWidth: 0,\n padding: `${px(9)}px ${px(9)}px ${px(9)}px ${px(20)}px`,\n }}\n >\n <span style={{flex: 1, minWidth: 0}}>\n {sent ? (\n \"iMessage\"\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: colors.blue,\n display: \"inline-block\",\n height: px(22),\n marginLeft: px(2),\n transform: `translateY(${px(4)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </span>\n {sent ? (\n /* An empty field carries the audio and emoji controls; the\n send arrow replaces them only once there is something to\n send, which is the swap Messages does. */\n <span style={{alignItems: \"center\", color: colors.faint, display: \"flex\", flex: \"none\", gap: px(12)}}>\n <svg viewBox=\"0 0 24 24\" width={px(24)} height={px(24)} stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\">\n <path d=\"M4 11v2M8 8v8M12 5v14M16 8v8M20 11v2\" />\n </svg>\n <svg viewBox=\"0 0 24 24\" width={px(26)} height={px(26)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.7}>\n <circle cx=\"12\" cy=\"12\" r=\"9.2\" />\n <circle cx=\"9\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <circle cx=\"15\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n <path d=\"M8.4 14.2a4.6 4.6 0 0 0 7.2 0\" strokeLinecap=\"round\" />\n </svg>\n </span>\n ) : (\n <span\n style={{\n alignItems: \"center\",\n background: colors.blue,\n borderRadius: px(999),\n display: \"flex\",\n flex: \"none\",\n height: px(30),\n justifyContent: \"center\",\n width: px(30),\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(18)} height={px(18)} fill=\"none\" stroke=\"#FFFFFF\" strokeWidth={2.6}>\n <path d=\"M12 19V6M6 12l6-6 6 6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </span>\n )}\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
2415
2415
  "target": "videos/components/imessage/imessage.tsx"
2416
2416
  },
2417
2417
  {
@@ -2539,12 +2539,12 @@
2539
2539
  "files": [
2540
2540
  {
2541
2541
  "path": "components/linear/linear.tsx",
2542
- "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type LinearProps = {\n /** The workspace name at the top of the sidebar. */\n workspace?: string;\n /** Issues listed in the sidebar's team section. */\n team?: string;\n /** The issue identifier. */\n id?: string;\n title?: string;\n /** The issue body, above the activity. */\n body?: string;\n /** The status it starts in, and the one the agent moves it to. */\n status?: string;\n movesTo?: string;\n priority?: string;\n labels?: string[];\n assignee?: string;\n /** What a person asks in the comment box, typed in and posted. */\n request?: {author: string; body: string};\n /** The agent's comment, which arrives whole after a working indicator. */\n agent?: {name: string; body: string};\n thinkingFrames?: number;\n charactersPerSecond?: number;\n};\n\n/** Linear's dark theme, stacked by luminance the way Linear stacks it. */\nconst CANVAS = \"#121213\";\nconst PANEL = \"#161617\";\nconst SURFACE = \"#17181A\";\nconst HOVER = \"#1A1A1B\";\nconst INK = \"#FFFFFF\";\nconst SECOND = \"#E3E4E6\";\nconst FAINT = \"#959597\";\nconst EDGE = \"#1A1B1D\";\nconst FONT = '\"Inter Variable\", Inter, -apple-system, \"SF Pro Display\", \"Segoe UI\", sans-serif';\nconst ACCENT = \"#5E6AD2\";\nconst PROGRESS = \"#E2A336\";\n\n/**\n * A Linear workspace with an agent working an issue and closing it.\n *\n * The whole app is drawn because an issue tracker is a place, not a card: the\n * sidebar with its inbox and its teams, the issue in the middle, and the\n * properties rail on the right where status actually lives. That rail is why\n * the shot works. The comment is what the agent wrote, the status chip is what\n * it changed, and they sit far enough apart that the eye reads the sentence\n * first and catches the state change second.\n */\nexport const Linear = ({\n workspace = \"Odori\",\n team = \"Engineering\",\n id = \"ENG-128\",\n title = \"Export size differs between identical commits\",\n body = \"Two runs of the same commit produced files 40MB apart. Suspect the encoder defaults differ between machines.\",\n status = \"In Progress\",\n movesTo = \"Done\",\n priority = \"Urgent\",\n labels = [\"render\", \"bug\"],\n assignee = \"Odori\",\n request = {author: \"John Doe\", body: \"Can you work out why and pin whatever is drifting?\"},\n agent = {\n name: \"Odori\",\n body: \"Pinned both binaries and added a regression test. The two runs are byte identical now.\",\n },\n thinkingFrames = 30,\n charactersPerSecond = 34,\n}: LinearProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n /* The typing is the person's: they write the ask into the comment box and\n post it, and the agent's comment arrives whole. */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(request.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 16;\n const commentAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(request.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const posted = spring({frame, fps, delayInFrames: commentAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const landed = frame >= commentAt;\n const working = frame >= workingAt && frame < commentAt;\n const moved = interpolate(landed ? frame : 0, [0, 14], [0, 1], {easing: Easing.standard});\n const done = moved > 0.5;\n\n const sidebarRow = (label: string, active = false) => (\n <div\n key={label}\n style={{\n background: active ? HOVER : \"transparent\",\n borderRadius: px(6),\n color: active ? INK : FAINT,\n fontSize: px(19),\n margin: `${px(1)}px ${px(10)}px`,\n padding: `${px(8)}px ${px(12)}px`,\n }}\n >\n {label}\n </div>\n );\n\n const property = (label: string, value: ReactNode) => (\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12), padding: `${px(10)}px 0`}}>\n <span style={{color: FAINT, fontSize: px(18), width: px(110)}}>{label}</span>\n <span style={{color: INK, fontSize: px(18)}}>{value}</span>\n </div>\n );\n\n return (\n <Fill style={{background: \"#000000\", padding: px(60)}}>\n <div\n style={{\n background: CANVAS,\n borderRadius: px(14),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The sidebar. */}\n <div style={{background: PANEL, borderRight: `${px(1)}px solid ${EDGE}`, flex: \"none\", paddingTop: px(20), width: px(280)}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10), padding: `0 ${px(20)}px ${px(20)}px`}}>\n <span style={{background: ACCENT, borderRadius: px(7), height: px(28), width: px(28)}} />\n <span style={{color: INK, fontSize: px(21), fontWeight: 600}}>{workspace}</span>\n </div>\n {[\"Inbox\", \"My Issues\"].map((label) => sidebarRow(label))}\n <div style={{color: FAINT, fontSize: px(15), padding: `${px(20)}px ${px(22)}px ${px(6)}px`}}>{team}</div>\n {[\"Triage\", \"Active\", \"Backlog\"].map((label) => sidebarRow(label, label === \"Active\"))}\n </div>\n\n {/* The issue. */}\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div\n style={{\n alignItems: \"center\",\n borderBottom: `${px(1)}px solid ${EDGE}`,\n color: FAINT,\n display: \"flex\",\n fontSize: px(18),\n gap: px(10),\n padding: `${px(16)}px ${px(28)}px`,\n }}\n >\n <span>{team}</span>\n <span>›</span>\n <span style={{color: INK, fontFamily: brand.typography.mono}}>{id}</span>\n </div>\n\n <div style={{flex: 1, overflow: \"hidden\", padding: `${px(28)}px ${px(28)}px`}}>\n <div style={{color: INK, fontSize: px(34), fontWeight: 500, letterSpacing: \"-0.02em\"}}>{title}</div>\n <div style={{color: SECOND, fontSize: px(20), lineHeight: 1.6, marginTop: px(14)}}>{body}</div>\n\n <div style={{borderTop: `${px(1)}px solid ${EDGE}`, marginTop: px(26), paddingTop: px(22)}}>\n {sent ? (\n <div style={{marginBottom: px(16), opacity: asked, transform: `translateY(${(1 - asked) * px(8)}px)`}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10)}}>\n <span style={{background: \"#5E6AD2\", borderRadius: px(999), height: px(28), width: px(28)}} />\n <span style={{color: INK, fontSize: px(19), fontWeight: 600}}>{request.author}</span>\n </div>\n <div style={{color: INK, fontSize: px(20), lineHeight: 1.55, marginTop: px(10), paddingLeft: px(38)}}>\n {request.body}\n </div>\n </div>\n ) : null}\n\n <div\n style={{\n background: SURFACE,\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(10),\n opacity: working ? 1 : posted,\n padding: `${px(18)}px ${px(20)}px`,\n transform: `translateY(${(1 - (working ? 1 : posted)) * px(8)}px)`,\n }}\n >\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10)}}>\n <span style={{background: ACCENT, borderRadius: px(6), height: px(28), width: px(28)}} />\n <span style={{color: INK, fontSize: px(19), fontWeight: 600}}>{agent.name}</span>\n <span\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(999),\n color: FAINT,\n fontSize: px(14),\n padding: `${px(1)}px ${px(8)}px`,\n }}\n >\n agent\n </span>\n </div>\n <div style={{marginTop: px(12), minHeight: px(34), paddingLeft: px(28 + 10)}}>\n {working ? (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(7), height: px(26)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: FAINT,\n borderRadius: px(999),\n height: px(9),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(9),\n }}\n />\n ))}\n </span>\n ) : (\n <span style={{color: INK, fontSize: px(20), lineHeight: 1.55}}>{agent.body}</span>\n )}\n </div>\n </div>\n\n {landed ? (\n <div style={{color: FAINT, fontSize: px(18), marginTop: px(16), opacity: moved, paddingLeft: px(4)}}>\n {agent.name} changed status from {status} to {movesTo}\n </div>\n ) : null}\n\n {/* The comment box, where the ask is actually written. */}\n <div\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(10),\n color: sent ? FAINT : INK,\n fontSize: px(19),\n lineHeight: 1.55,\n marginTop: px(20),\n minHeight: px(30),\n padding: `${px(14)}px ${px(18)}px`,\n }}\n >\n {sent ? (\n \"Leave a comment...\"\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: INK,\n display: \"inline-block\",\n height: px(19),\n marginLeft: px(2),\n transform: `translateY(${px(3)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </div>\n </div>\n </div>\n </div>\n\n {/* The properties rail, where status actually lives. */}\n <div style={{borderLeft: `${px(1)}px solid ${EDGE}`, flex: \"none\", padding: `${px(24)}px ${px(24)}px`, width: px(380)}}>\n <div style={{color: FAINT, fontSize: px(15), letterSpacing: \"0.04em\", marginBottom: px(8)}}>PROPERTIES</div>\n {property(\n \"Status\",\n <span\n style={{\n alignItems: \"center\",\n color: done ? \"#A5AEF5\" : PROGRESS,\n display: \"inline-flex\",\n gap: px(9),\n transform: `scale(${1 + Math.sin(Math.min(1, moved) * Math.PI) * 0.07})`,\n }}\n >\n <span\n style={{\n background: done ? ACCENT : \"transparent\",\n border: `${px(2)}px solid ${done ? ACCENT : PROGRESS}`,\n borderRadius: px(999),\n height: px(13),\n width: px(13),\n }}\n />\n {done ? movesTo : status}\n </span>,\n )}\n {property(\"Priority\", priority)}\n {property(\"Assignee\", assignee)}\n {property(\n \"Labels\",\n <span style={{display: \"flex\", flexWrap: \"wrap\", gap: px(6)}}>\n {labels.map((label) => (\n <span\n key={label}\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(999),\n color: FAINT,\n fontSize: px(16),\n padding: `${px(4)}px ${px(11)}px`,\n }}\n >\n {label}\n </span>\n ))}\n </span>,\n )}\n </div>\n </div>\n </Fill>\n );\n};\n",
2542
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type LinearProps = {\n /** The workspace name, beside its mark at the top of the sidebar. */\n workspace?: string;\n /** The team whose section the issue belongs to. */\n team?: string;\n /** The issue identifier. */\n id?: string;\n title?: string;\n /** The issue body, above the activity. */\n body?: string;\n /** The status it starts in, and the one the agent moves it to. */\n status?: string;\n movesTo?: string;\n priority?: string;\n labels?: string[];\n assignee?: string;\n /** What a person asks in the comment box, typed in and posted. */\n request?: {author: string; body: string};\n /** The agent's comment, which arrives whole after a working indicator. */\n agent?: {name: string; body: string};\n thinkingFrames?: number;\n theme?: \"light\" | \"dark\";\n charactersPerSecond?: number;\n};\n\n/**\n * Linear's palette, read off the running app.\n *\n * Linear authors its colours in LCH and steps them by lightness rather than\n * by name, so these are that ladder resolved to sRGB: four grounds, four\n * inks, two borders. Guessing them produced a workspace a shade too dark\n * and a shade too contrasty at every level at once.\n */\nconst THEMES = {\n light: {\n app: \"#F9F9FA\",\n pane: \"#FFFFFF\",\n hover: \"#EBEBEC\",\n sunken: \"#EFEFF0\",\n ink: \"#1B1B1B\",\n second: \"#2F2F31\",\n third: \"#5C5C5E\",\n fourth: \"#9C9D9F\",\n edge: \"#F1F1F1\",\n edgeStrong: \"#DEDEDE\",\n },\n dark: {\n app: \"#121213\",\n pane: \"#161617\",\n hover: \"#1F1F21\",\n sunken: \"#1A1A1B\",\n ink: \"#F7F8F8\",\n second: \"#E3E4E6\",\n third: \"#9C9D9F\",\n fourth: \"#6F7071\",\n edge: \"#1F1F21\",\n edgeStrong: \"#2A2A2C\",\n },\n};\n\nconst FONT = '\"Inter Variable\", Inter, \"SF Pro Display\", -apple-system, \"Segoe UI\", Roboto, sans-serif';\nconst ACCENT = \"#5E6AD2\";\nconst PROGRESS = \"#E2A336\";\nconst DONE = \"#5E6AD2\";\nconst MARK = \"#D6247F\";\nconst AVATAR = \"#0FA891\";\n\n/**\n * A Linear workspace with an agent working an issue and closing it.\n *\n * The whole app is drawn because an issue tracker is a place, not a card: the\n * sidebar with its inbox and its teams, the issue in the middle, and the\n * properties rail on the right where status actually lives. That rail is why\n * the shot works. The comment is what the agent wrote, the status chip is what\n * it changed, and they sit far enough apart that the eye reads the sentence\n * first and catches the state change second.\n */\nexport const Linear = ({\n workspace = \"Odori\",\n team = \"Engineering\",\n id = \"ENG-128\",\n title = \"Export size differs between identical commits\",\n body = \"Two runs of the same commit produced files 40MB apart. Suspect the encoder defaults differ between machines.\",\n status = \"In Progress\",\n movesTo = \"Done\",\n priority = \"Urgent\",\n labels = [\"render\", \"bug\"],\n assignee = \"johndoe\",\n request = {author: \"John Doe\", body: \"Can you work out why and pin whatever is drifting?\"},\n agent = {\n name: \"Odori\",\n body: \"Pinned both binaries and added a regression test. The two runs are byte identical now.\",\n },\n thinkingFrames = 30,\n theme = \"light\",\n charactersPerSecond = 34,\n}: LinearProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const scale = useDesignScale();\n const colors = THEMES[theme];\n const px = (value: number) => value * scale;\n\n /* The typing is the person's: they write the ask into the comment box and\n post it, and the agent's comment arrives whole. */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(request.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 16;\n const commentAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(request.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const posted = spring({frame, fps, delayInFrames: commentAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const landed = frame >= commentAt;\n const working = frame >= workingAt && frame < commentAt;\n const moved = interpolate(landed ? frame : 0, [0, 14], [0, 1], {easing: Easing.standard});\n const done = moved > 0.5;\n\n /** The round member mark Linear draws for a person with no picture. */\n const person = (name: string, size: number) => (\n <span\n style={{\n alignItems: \"center\",\n background: AVATAR,\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(size * 0.42),\n fontWeight: 500,\n height: px(size),\n justifyContent: \"center\",\n width: px(size),\n }}\n >\n {name.slice(0, 2).toUpperCase()}\n </span>\n );\n\n /** A sidebar row: an icon slot, then the label, at the row height Linear uses. */\n const row = (label: string, icon: ReactNode, options: {active?: boolean; indent?: number; dot?: boolean} = {}) => (\n <div\n key={label}\n style={{\n alignItems: \"center\",\n background: options.active ? colors.hover : \"transparent\",\n borderRadius: px(10),\n color: options.active ? colors.ink : colors.second,\n display: \"flex\",\n fontSize: px(24),\n gap: px(12),\n height: px(42),\n margin: `${px(1)}px ${px(12)}px`,\n padding: `0 ${px(12)}px 0 ${px(12 + (options.indent ?? 0))}px`,\n }}\n >\n <span style={{alignItems: \"center\", color: colors.third, display: \"flex\", flex: \"none\", height: px(24), width: px(24)}}>\n {icon}\n </span>\n {label}\n {options.dot ? (\n <span style={{background: ACCENT, borderRadius: px(999), height: px(10), marginLeft: \"auto\", width: px(10)}} />\n ) : null}\n </div>\n );\n\n /** A collapsible sidebar heading, which Linear sets small and tertiary. */\n const heading = (label: string) => (\n <div\n key={label}\n style={{\n alignItems: \"center\",\n color: colors.third,\n display: \"flex\",\n fontSize: px(21),\n fontWeight: 500,\n gap: px(6),\n margin: `${px(18)}px ${px(12)}px ${px(2)}px`,\n padding: `0 ${px(12)}px`,\n }}\n >\n {label}\n <svg viewBox=\"0 0 20 20\" width={px(20)} height={px(20)} fill=\"currentColor\">\n <path d=\"M5.5 8h9L10 13.5z\" />\n </svg>\n </div>\n );\n\n const stroke = (d: string, weight = 1.7) => (\n <svg viewBox=\"0 0 20 20\" width={px(24)} height={px(24)} fill=\"none\" stroke=\"currentColor\" strokeWidth={weight} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n <path d={d} />\n </svg>\n );\n\n /** The dashed ring Linear draws for Backlog, and the arc for In Progress. */\n const statusMark = (size: number) =>\n done ? (\n <svg viewBox=\"0 0 20 20\" width={px(size)} height={px(size)}>\n <circle cx=\"10\" cy=\"10\" r=\"8\" fill={DONE} />\n <path d=\"M6.4 10.2l2.5 2.5 4.8-5.1\" fill=\"none\" stroke=\"#FFFFFF\" strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2.2} />\n </svg>\n ) : (\n <svg viewBox=\"0 0 20 20\" width={px(size)} height={px(size)}>\n <circle cx=\"10\" cy=\"10\" r=\"7\" fill=\"none\" stroke={PROGRESS} strokeWidth={2} />\n <path d=\"M10 10V4a6 6 0 0 1 6 6z\" fill={PROGRESS} />\n </svg>\n );\n\n const property = (label: ReactNode) => (\n <div style={{alignItems: \"center\", color: colors.second, display: \"flex\", fontSize: px(24), gap: px(10), padding: `${px(7)}px 0`}}>\n {label}\n </div>\n );\n\n const railHeading = (label: string) => (\n <div style={{color: colors.third, fontSize: px(22), fontWeight: 500, margin: `${px(26)}px 0 ${px(8)}px`}}>{label}</div>\n );\n\n return (\n <Fill style={{background: colors.app, padding: px(52)}}>\n <div\n style={{\n background: colors.app,\n borderRadius: px(14),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The sidebar sits on the app ground; the issue floats on white. */}\n <div style={{display: \"flex\", flex: \"none\", flexDirection: \"column\", paddingTop: px(14), width: px(330)}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10), padding: `0 ${px(20)}px ${px(14)}px`}}>\n <span\n style={{\n alignItems: \"center\",\n background: MARK,\n borderRadius: px(8),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(15),\n fontWeight: 700,\n height: px(32),\n justifyContent: \"center\",\n width: px(32),\n }}\n >\n {workspace.slice(0, 3).toUpperCase()}\n </span>\n <span style={{color: colors.ink, fontSize: px(24), fontWeight: 500}}>{workspace}</span>\n <svg viewBox=\"0 0 20 20\" width={px(20)} height={px(20)} fill={colors.third} style={{flex: \"none\"}}>\n <path d=\"M5.5 8h9L10 13.5z\" />\n </svg>\n <span style={{alignItems: \"center\", color: colors.third, display: \"flex\", gap: px(14), marginLeft: \"auto\"}}>\n {stroke(\"M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11M13 13l4 4\")}\n {stroke(\"M4 16h12M12.5 4.5l3 3L8 15H5v-3z\")}\n </span>\n </div>\n\n {row(\"Inbox\", stroke(\"M3 11h4l1.5 2.5h3L13 11h4M3 11l2.5-6h9L17 11v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"))}\n {row(\"My issues\", stroke(\"M10 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13M10 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4\"), {active: true})}\n {row(\"Reviews\", stroke(\"M6 4v8M6 16a2 2 0 1 0 0-4 2 2 0 0 0 0 4M14 4a2 2 0 1 0 0 4 2 2 0 0 0 0-4M14 8v2a4 4 0 0 1-4 4H8\"), {dot: true})}\n {row(\"Agent\", stroke(\"M4 4l12 6-12 6z\"))}\n\n {heading(\"Workspace\")}\n {row(\"Projects\", stroke(\"M10 3l6.5 3.6v6.8L10 17l-6.5-3.6V6.6zM3.5 6.6L10 10.2l6.5-3.6M10 10.2V17\"))}\n {row(\"Views\", stroke(\"M10 3.5l7 3.6-7 3.6-7-3.6zM3 11.4l7 3.6 7-3.6\"))}\n {row(\"More\", stroke(\"M5 10h.01M10 10h.01M15 10h.01\", 2.6))}\n\n {heading(\"Your teams\")}\n <div\n style={{\n alignItems: \"center\",\n color: colors.ink,\n display: \"flex\",\n fontSize: px(24),\n gap: px(12),\n height: px(42),\n margin: `${px(1)}px ${px(12)}px`,\n padding: `0 ${px(12)}px`,\n }}\n >\n <span style={{alignItems: \"center\", color: MARK, display: \"flex\", flex: \"none\", height: px(24), width: px(24)}}>\n {stroke(\"M4 4l12 12M16 4L4 16\", 2)}\n </span>\n {team}\n <svg viewBox=\"0 0 20 20\" width={px(20)} height={px(20)} fill={colors.third} style={{marginLeft: px(4)}}>\n <path d=\"M5.5 8h9L10 13.5z\" />\n </svg>\n </div>\n {row(\"Home\", stroke(\"M4 9l6-5 6 5v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z\"), {indent: 20})}\n {row(\"Issues\", stroke(\"M5 5h10v10H5zM5 8h10\"), {indent: 20})}\n {row(\"Projects\", stroke(\"M10 3l6.5 3.6v6.8L10 17l-6.5-3.6V6.6z\"), {indent: 20})}\n {row(\"Views\", stroke(\"M10 3.5l7 3.6-7 3.6-7-3.6z\"), {indent: 20})}\n\n {heading(\"Try\")}\n {row(\"Initiatives\", stroke(\"M10 4a6 6 0 0 0-6 6M10 4a6 6 0 0 1 6 6M4 10h12M10 4v12\"))}\n </div>\n\n {/* The issue, on its own panel. */}\n <div\n style={{\n background: colors.pane,\n border: `${px(1)}px solid ${colors.edgeStrong}`,\n borderRadius: px(12),\n display: \"flex\",\n flex: 1,\n flexDirection: \"column\",\n margin: `${px(8)}px ${px(8)}px ${px(8)}px 0`,\n minWidth: 0,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n color: colors.second,\n display: \"flex\",\n fontSize: px(24),\n gap: px(10),\n padding: `${px(18)}px ${px(24)}px`,\n }}\n >\n <span style={{color: colors.third}}>My issues</span>\n <span style={{color: colors.fourth}}>›</span>\n <span style={{color: colors.third}}>{id}</span>\n <span style={{color: colors.ink}}>{title.split(\" \").slice(0, 4).join(\" \")}</span>\n <span style={{alignItems: \"center\", color: colors.fourth, display: \"flex\", gap: px(14), marginLeft: px(6)}}>\n {stroke(\"M10 3.5l2 4.2 4.5.6-3.3 3.2.8 4.5-4-2.1-4 2.1.8-4.5L3.5 8.3l4.5-.6z\")}\n {stroke(\"M5 10h.01M10 10h.01M15 10h.01\", 2.6)}\n </span>\n </div>\n\n <div style={{display: \"flex\", flex: 1, minHeight: 0}}>\n <div style={{flex: 1, minWidth: 0, padding: `${px(18)}px ${px(56)}px`}}>\n <div style={{color: colors.ink, fontSize: px(46), fontWeight: 600, letterSpacing: \"-0.02em\"}}>{title}</div>\n <div style={{color: colors.third, fontSize: px(26), lineHeight: 1.6, marginTop: px(16)}}>{body}</div>\n <div style={{color: colors.fourth, fontSize: px(24), marginTop: px(22)}}>+ Add sub-issues</div>\n\n <div style={{borderTop: `${px(1)}px solid ${colors.edgeStrong}`, marginTop: px(30), paddingTop: px(24)}}>\n <div style={{color: colors.ink, fontSize: px(26), fontWeight: 600}}>Activity</div>\n\n <div style={{alignItems: \"center\", color: colors.third, display: \"flex\", fontSize: px(23), gap: px(12), marginTop: px(16)}}>\n {person(request.author, 26)}\n <span>\n <span style={{color: colors.second}}>{request.author}</span> created the issue · just now\n </span>\n </div>\n\n {sent ? (\n <div style={{marginTop: px(20), opacity: asked, transform: `translateY(${(1 - asked) * px(8)}px)`}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12)}}>\n {person(request.author, 26)}\n <span style={{color: colors.ink, fontSize: px(23), fontWeight: 500}}>{request.author}</span>\n </div>\n <div style={{color: colors.second, fontSize: px(24), lineHeight: 1.6, marginTop: px(8), paddingLeft: px(38)}}>\n {request.body}\n </div>\n </div>\n ) : null}\n\n {frame >= workingAt ? (\n <div\n style={{\n marginTop: px(20),\n opacity: working ? 1 : posted,\n transform: `translateY(${(1 - (working ? 1 : posted)) * px(8)}px)`,\n }}\n >\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12)}}>\n <span style={{background: ACCENT, borderRadius: px(7), flex: \"none\", height: px(26), width: px(26)}} />\n <span style={{color: colors.ink, fontSize: px(23), fontWeight: 500}}>{agent.name}</span>\n <span\n style={{\n border: `${px(1)}px solid ${colors.edgeStrong}`,\n borderRadius: px(999),\n color: colors.third,\n fontSize: px(17),\n padding: `${px(1)}px ${px(9)}px`,\n }}\n >\n agent\n </span>\n </div>\n <div style={{minHeight: px(34), paddingLeft: px(38)}}>\n {working ? (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(7), height: px(34)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: colors.fourth,\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 <div style={{color: colors.second, fontSize: px(24), lineHeight: 1.6, marginTop: px(8)}}>{agent.body}</div>\n )}\n </div>\n </div>\n ) : null}\n\n {landed ? (\n <div style={{alignItems: \"center\", color: colors.third, display: \"flex\", fontSize: px(23), gap: px(12), marginTop: px(18), opacity: moved}}>\n <span style={{background: ACCENT, borderRadius: px(7), flex: \"none\", height: px(26), width: px(26)}} />\n <span>\n <span style={{color: colors.second}}>{agent.name}</span> changed status from {status} to {movesTo}\n </span>\n </div>\n ) : null}\n\n {/* The comment box, where the ask is written. */}\n <div\n style={{\n border: `${px(1)}px solid ${colors.edgeStrong}`,\n borderRadius: px(10),\n color: sent ? colors.fourth : colors.ink,\n fontSize: px(24),\n lineHeight: 1.6,\n marginTop: px(24),\n minHeight: px(64),\n padding: `${px(16)}px ${px(18)}px`,\n }}\n >\n {sent ? (\n \"Leave a comment...\"\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(24),\n marginLeft: px(2),\n transform: `translateY(${px(4)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </div>\n </div>\n </div>\n\n {/* The properties rail, where status actually lives. */}\n <div style={{flex: \"none\", padding: `${px(18)}px ${px(28)}px`, width: px(400)}}>\n <div style={{color: colors.third, fontSize: px(22), fontWeight: 500}}>Properties</div>\n {property(\n <>\n {statusMark(24)}\n <span\n style={{\n color: done ? colors.ink : colors.second,\n transform: `scale(${1 + Math.sin(Math.min(1, moved) * Math.PI) * 0.06})`,\n }}\n >\n {done ? movesTo : status}\n </span>\n </>,\n )}\n {property(\n <>\n <span style={{alignItems: \"center\", color: colors.third, display: \"flex\", height: px(24), width: px(24)}}>\n {stroke(\"M4 13h2.5M8.75 9.5h2.5M13.5 6h2.5\", 2.2)}\n </span>\n <span>{priority}</span>\n </>,\n )}\n {property(\n <>\n {person(assignee, 24)}\n <span>{assignee}</span>\n </>,\n )}\n\n {railHeading(\"Labels\")}\n <div style={{display: \"flex\", flexWrap: \"wrap\", gap: px(8)}}>\n {labels.map((label) => (\n <span\n key={label}\n style={{\n alignItems: \"center\",\n border: `${px(1)}px solid ${colors.edgeStrong}`,\n borderRadius: px(999),\n color: colors.second,\n display: \"inline-flex\",\n fontSize: px(21),\n gap: px(8),\n padding: `${px(4)}px ${px(12)}px`,\n }}\n >\n <span style={{background: colors.fourth, borderRadius: px(999), height: px(9), width: px(9)}} />\n {label}\n </span>\n ))}\n </div>\n\n {railHeading(\"Project\")}\n <div style={{alignItems: \"center\", color: colors.fourth, display: \"flex\", fontSize: px(24), gap: px(10)}}>\n <span style={{alignItems: \"center\", display: \"flex\", height: px(24), width: px(24)}}>\n {stroke(\"M10 3l6.5 3.6v6.8L10 17l-6.5-3.6V6.6z\")}\n </span>\n Add to project\n </div>\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
2543
2543
  "target": "videos/components/linear/linear.tsx"
2544
2544
  },
2545
2545
  {
2546
2546
  "path": "components/linear/linear.preview.tsx",
2547
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Linear} from \"./linear\";\n\nexport default defineComponentPreview({\n title: \"Linear\",\n category: \"Products\",\n description: \"A Linear workspace where an agent comments on an issue and moves it to done.\",\n component: Linear,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n workspace: {type: \"text\", defaultValue: \"Odori\", maxLength: 24},\n team: {type: \"text\", defaultValue: \"Engineering\", maxLength: 24},\n id: {type: \"text\", defaultValue: \"ENG-128\", maxLength: 16},\n title: {type: \"text\", defaultValue: \"Export size differs between identical commits\", maxLength: 72},\n status: {type: \"text\", defaultValue: \"In Progress\", maxLength: 20},\n movesTo: {type: \"text\", defaultValue: \"Done\", maxLength: 20},\n thinkingFrames: {type: \"number\", defaultValue: 30, min: 0, max: 90, step: 2},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Closing it\", props: {}},\n {\n name: \"Triage\",\n props: {\n id: \"ENG-204\",\n title: \"Studio pane unusable beside an editor\",\n body: \"At editor width the inspector takes half the window and the stage keeps the leftovers.\",\n status: \"Triage\",\n movesTo: \"In Progress\",\n priority: \"High\",\n labels: [\"studio\"],\n agent: {name: \"Odori\", body: \"Reproduced at 380px. The inspector is fixed width; making it draggable fixes it.\"},\n },\n },\n ],\n});\n",
2547
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Linear} from \"./linear\";\n\nexport default defineComponentPreview({\n title: \"Linear\",\n category: \"Products\",\n description: \"A Linear workspace where an agent comments on an issue and moves it to done.\",\n component: Linear,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n workspace: {type: \"text\", defaultValue: \"Odori\", maxLength: 24},\n team: {type: \"text\", defaultValue: \"Engineering\", maxLength: 24},\n id: {type: \"text\", defaultValue: \"ENG-128\", maxLength: 16},\n title: {type: \"text\", defaultValue: \"Export size differs between identical commits\", maxLength: 72},\n status: {type: \"text\", defaultValue: \"In Progress\", maxLength: 20},\n movesTo: {type: \"text\", defaultValue: \"Done\", maxLength: 20},\n thinkingFrames: {type: \"number\", defaultValue: 30, min: 0, max: 90, step: 2},\n theme: {type: \"select\", defaultValue: \"light\", options: [\"light\", \"dark\"]},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Closing it\", props: {}},\n {\n name: \"Triage\",\n props: {\n id: \"ENG-204\",\n title: \"Studio pane unusable beside an editor\",\n body: \"At editor width the inspector takes half the window and the stage keeps the leftovers.\",\n status: \"Triage\",\n movesTo: \"In Progress\",\n priority: \"High\",\n labels: [\"studio\"],\n agent: {name: \"Odori\", body: \"Reproduced at 380px. The inspector is fixed width; making it draggable fixes it.\"},\n },\n },\n ],\n});\n",
2548
2548
  "target": "videos/components/linear/linear.preview.tsx"
2549
2549
  }
2550
2550
  ],
@@ -3401,7 +3401,7 @@
3401
3401
  "files": [
3402
3402
  {
3403
3403
  "path": "components/slack/slack.tsx",
3404
- "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type SlackPost = {\n author: string;\n body: string;\n time?: string;\n /** Marks the sender as an app rather than a person. */\n app?: boolean;\n /** The avatar tile colour. */\n color?: string;\n /** A name mentioned at the head of the message, drawn as a link. */\n mention?: string;\n};\n\nexport type SlackProps = {\n workspace?: string;\n /** Channels in the rail. A string is public; an object can be private. */\n channels?: (string | {name: string; private?: boolean})[];\n directMessages?: string[];\n channel?: string;\n /** The message that lands and gets answered. */\n ask?: SlackPost;\n /** A reaction on that message. */\n reaction?: {emoji: string; count: number; mine?: boolean};\n /** The app's reply, typed into the thread under it. */\n reply?: SlackPost;\n thinkingFrames?: number;\n theme?: \"aubergine\" | \"dark\";\n charactersPerSecond?: number;\n};\n\n/**\n * Slack's two looks, measured off the running client.\n *\n * The selected channel is a saturated aubergine that Slack keeps in both\n * themes, not the muted one a screenshot suggests, and the sidebar's text is a\n * lavender at eight tenths rather than a flat grey.\n */\n/**\n * The composer's icons, on Slack's own 20 grid.\n *\n * Drawn by eye these never landed: the set is specific (underline and a\n * separate code block, no camera or microphone) and so is the weight.\n */\nconst COMPOSER_ICONS = {\n bold:\n \"M4 2.75A.75.75 0 0 1 4.75 2h6.343a3.91 3.91 0 0 1 3.88 3.449A2 2 0 0 1 15 5.84l.001.067a3.9 3.9 0 0 1-1.551 3.118A4.627 4.627 0 0 1 11.875 18H4.75a.75.75 0 0 1-.75-.75V9.5a.8.8 0 0 1 .032-.218A.8.8 0 0 1 4 9.065zm2.5 5.565h3.593a2.157 2.157 0 1 0 0-4.315H6.5zm4.25 1.935H6.5v5.5h4.25a2.75 2.75 0 1 0 0-5.5\",\n italic:\n \"M7 2.75A.75.75 0 0 1 7.75 2h7.5a.75.75 0 0 1 0 1.5H12.3l-2.6 13h2.55a.75.75 0 0 1 0 1.5h-7.5a.75.75 0 0 1 0-1.5H7.7l2.6-13H7.75A.75.75 0 0 1 7 2.75\",\n underline:\n \"M17.25 17.12a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5zM14.5 1.63a.75.75 0 0 1 .75.75v8a5.25 5.25 0 1 1-10.5 0v-8a.75.75 0 0 1 1.5 0v8a3.75 3.75 0 0 0 7.5 0v-8a.75.75 0 0 1 .75-.75\",\n strike:\n \"M11.721 3.84c-.91-.334-2.028-.36-3.035-.114-1.51.407-2.379 1.861-2.164 3.15C6.718 8.051 7.939 9.5 11.5 9.5l.027.001h5.723a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5h3.66c-.76-.649-1.216-1.468-1.368-2.377-.347-2.084 1.033-4.253 3.265-4.848l.007-.002.007-.002c1.252-.307 2.68-.292 3.915.16 1.252.457 2.337 1.381 2.738 2.874a.75.75 0 0 1-1.448.39c-.25-.925-.91-1.528-1.805-1.856m2.968 9.114a.75.75 0 1 0-1.378.59c.273.64.186 1.205-.13 1.674-.333.492-.958.925-1.82 1.137-.989.243-1.991.165-3.029-.124-.93-.26-1.613-.935-1.858-1.845a.75.75 0 0 0-1.448.39c.388 1.441 1.483 2.503 2.903 2.9 1.213.338 2.486.456 3.79.135 1.14-.28 2.12-.889 2.704-1.753.6-.888.743-1.992.266-3.104\",\n link:\n \"M12.306 3.756a2.75 2.75 0 0 1 3.889 0l.05.05a2.75 2.75 0 0 1 0 3.889l-3.18 3.18a2.75 2.75 0 0 1-3.98-.095l-.03-.034a.75.75 0 0 0-1.11 1.009l.03.034a4.25 4.25 0 0 0 6.15.146l3.18-3.18a4.25 4.25 0 0 0 0-6.01l-.05-.05a4.25 4.25 0 0 0-6.01 0L9.47 4.47a.75.75 0 1 0 1.06 1.06zm-4.611 12.49a2.75 2.75 0 0 1-3.89 0l-.05-.051a2.75 2.75 0 0 1 0-3.89l3.18-3.179a2.75 2.75 0 0 1 3.98.095l.03.034a.75.75 0 1 0 1.11-1.01l-.03-.033a4.25 4.25 0 0 0-6.15-.146l-3.18 3.18a4.25 4.25 0 0 0 0 6.01l.05.05a4.25 4.25 0 0 0 6.01 0l1.775-1.775a.75.75 0 0 0-1.06-1.06z\",\n ordered:\n \"M3.792 2.094A.5.5 0 0 1 4 2.5V6h1a.5.5 0 1 1 0 1H2a.5.5 0 1 1 0-1h1V3.194l-.842.28a.5.5 0 0 1-.316-.948l1.5-.5a.5.5 0 0 1 .45.068M7.75 3.5a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zM7 10.75a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10a.75.75 0 0 1-.75-.75m0 6.5a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10a.75.75 0 0 1-.75-.75m-4.293-3.36a1 1 0 0 1 .793-.39c.49 0 .75.38.75.75 0 .064-.033.194-.173.409a5 5 0 0 1-.594.711c-.256.267-.552.548-.87.848l-.088.084a42 42 0 0 0-.879.845A.5.5 0 0 0 2 18h3a.5.5 0 0 0 0-1H3.242l.058-.055c.316-.298.629-.595.904-.882a6 6 0 0 0 .711-.859c.18-.277.335-.604.335-.954 0-.787-.582-1.75-1.75-1.75a2 2 0 0 0-1.81 1.147.5.5 0 1 0 .905.427 1 1 0 0 1 .112-.184\",\n bulleted:\n \"M4 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10A.75.75 0 0 1 7 3m.75 6.25a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zm0 7a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zM3 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2m0 7a1 1 0 1 0 0-2 1 1 0 0 0 0 2\",\n quote:\n \"M3.5 2.75a.75.75 0 0 0-1.5 0v14.5a.75.75 0 0 0 1.5 0zM6.75 3a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5zM6 10.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H6.75a.75.75 0 0 1-.75-.75m.75 5.25a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5z\",\n code:\n \"M12.058 3.212c.396.12.62.54.5.936L8.87 16.29a.75.75 0 1 1-1.435-.436l3.686-12.143a.75.75 0 0 1 .936-.5M5.472 6.24a.75.75 0 0 1 .005 1.06l-2.67 2.693 2.67 2.691a.75.75 0 1 1-1.065 1.057l-3.194-3.22a.75.75 0 0 1 0-1.056l3.194-3.22a.75.75 0 0 1 1.06-.005m9.044 1.06a.75.75 0 1 1 1.065-1.056l3.194 3.221a.75.75 0 0 1 0 1.057l-3.194 3.219a.75.75 0 0 1-1.065-1.057l2.67-2.69z\",\n codeblock:\n \"M9.212 2.737a.75.75 0 1 0-1.424-.474l-2.5 7.5a.75.75 0 0 0 1.424.474zm6.038.265a.75.75 0 0 0 0 1.5h2a.25.25 0 0 1 .25.25v11.5a.25.25 0 0 1-.25.25h-13a.25.25 0 0 1-.25-.25v-3.5a.75.75 0 0 0-1.5 0v3.5c0 .966.784 1.75 1.75 1.75h13a1.75 1.75 0 0 0 1.75-1.75v-11.5a1.75 1.75 0 0 0-1.75-1.75zm-3.69.5a.75.75 0 1 0-1.12.996l1.556 1.754-1.556 1.75a.75.75 0 1 0 1.12.997l2-2.249a.75.75 0 0 0 0-.996zM3.999 9.061a.75.75 0 0 1-1.058-.062l-2-2.249a.75.75 0 0 1 0-.996l2-2.252a.75.75 0 1 1 1.12.996L2.504 6.252l1.557 1.75a.75.75 0 0 1-.062 1.059\",\n attach:\n \"M10.75 3.25a.75.75 0 0 0-1.5 0v6H3.251L3.25 10v-.75a.75.75 0 0 0 0 1.5V10v.75h6v6a.75.75 0 0 0 1.5 0v-6h6a.75.75 0 0 0 0-1.5h-6z\",\n format:\n \"M6.941 3.952c-.459-1.378-2.414-1.363-2.853.022l-4.053 12.8a.75.75 0 0 0 1.43.452l1.101-3.476h6.06l1.163 3.487a.75.75 0 1 0 1.423-.474zm1.185 8.298L5.518 4.427 3.041 12.25zm6.198-5.537a4.74 4.74 0 0 1 3.037-.081A3.74 3.74 0 0 1 20 10.208V17a.75.75 0 0 1-1.5 0v-.745a8 8 0 0 1-2.847 1.355 3 3 0 0 1-3.15-1.143C10.848 14.192 12.473 11 15.287 11H18.5v-.792c0-.984-.641-1.853-1.581-2.143a3.24 3.24 0 0 0-2.077.056l-.242.089a2.22 2.22 0 0 0-1.34 1.382l-.048.145a.75.75 0 0 1-1.423-.474l.048-.145a3.72 3.72 0 0 1 2.244-2.315zM18.5 12.5h-3.213c-1.587 0-2.504 1.801-1.57 3.085.357.491.98.717 1.572.57a6.5 6.5 0 0 0 2.47-1.223l.741-.593z\",\n emoji:\n \"M2.5 10a7.5 7.5 0 1 1 15 0 7.5 7.5 0 0 1-15 0M10 1a9 9 0 1 0 0 18 9 9 0 0 0 0-18M7.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3M14 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m-6.385 3.766a.75.75 0 1 0-1.425.468C6.796 14.08 8.428 15 10.027 15s3.23-.92 3.838-2.766a.75.75 0 1 0-1.425-.468c-.38 1.155-1.38 1.734-2.413 1.734s-2.032-.58-2.412-1.734\",\n mention:\n \"M2.5 10a7.5 7.5 0 1 1 15 0v.645c0 1.024-.83 1.855-1.855 1.855a1.145 1.145 0 0 1-1.145-1.145V6.75a.75.75 0 0 0-1.494-.098 4.5 4.5 0 1 0 .465 6.212A2.64 2.64 0 0 0 15.646 14 3.355 3.355 0 0 0 19 10.645V10a9 9 0 1 0-3.815 7.357.75.75 0 1 0-.865-1.225A7.5 7.5 0 0 1 2.5 10m7.5 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6\",\n send:\n \"M1.5 2.106c0-.462.498-.754.901-.528l15.7 7.714a.73.73 0 0 1 .006 1.307L2.501 18.46l-.07.017a.754.754 0 0 1-.931-.733v-4.572c0-1.22.971-2.246 2.213-2.268l6.547-.17c.27-.01.75-.243.75-.797 0-.553-.5-.795-.75-.795l-6.547-.171C2.47 8.95 1.5 7.924 1.5 6.704z\",\n} as const;\n\n/**\n * The channel hash, taken from inside Slack's own channels mark and scaled\n * to fill the grid. Typing a \"#\" gives the font's hash, which is upright\n * where Slack's is raked, and lands a size small beside the name.\n */\nconst CHANNEL_HASH =\n \"M8.652 5.465a.751.751 0 0 1 1.478.256l-.268 1.556h1.365l.313-1.81a.75.75 0 0 1 1.478.256l-.269 1.554h1.658a.751.751 0 0 1 0 1.5h-1.915l-.475 2.753H13.8a.75.75 0 0 1 0 1.5h-2.042l-.26 1.503a.75.75 0 0 1-1.478-.255l.215-1.248H8.869l-.26 1.501a.75.75 0 0 1-1.478-.255l.215-1.246H5.593a.75.75 0 0 1 .001-1.5h2.012l.475-2.753H6.2a.75.75 0 0 1 0-1.5h2.14zM9.128 11.53h1.366l.474-2.753H9.603z\";\n\n/**\n * The add-reaction control's face. Slack layers a solid disc beneath this\n * one; filling both gives a blob, and this path already carries the ring,\n * the plus, the eyes and the mouth.\n */\nconst ADD_REACTION =\n \"M15.5 1a.75.75 0 0 1 .75.75v2h2a.75.75 0 0 1 0 1.5h-2v2a.75.75 0 0 1-1.5 0v-2h-2a.75.75 0 0 1 0-1.5h2v-2A.75.75 0 0 1 15.5 1Zm-13 10a6.5 6.5 0 0 1 7.166-6.466.75.75 0 0 0 .152-1.493 8 8 0 1 0 7.14 7.139.75.75 0 0 0-1.492.152A6.525 6.525 0 0 1 15.5 11a6.5 6.5 0 1 1-13 0Zm4.25-.5a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm4.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9 15c1.277 0 2.553-.724 3.06-2.173.148-.426-.209-.827-.66-.827H6.6c-.452 0-.808.4-.66.827C6.448 14.276 7.724 15 9 15Z\";\n\n/** Slack's blue for a reaction you are part of: the ring and the ink. */\n/**\n * Slack's default picture is an initial on a flat ground in a rounded\n * square, at a shade over half the box and in a medium rather than a bold.\n */\nconst DEFAULT_AVATAR = \"#2B5C8A\";\n\nconst REACTED_WASH = \"#E3F8FF\";\nconst REACTED_INK = \"#1264A3\";\n\n/**\n * The rail Slack puts left of everything: the workspace, then Home, DMs,\n * Activity, Files and More, each an icon over its own label. Slack's own\n * paths, on its 20 grid.\n */\nconst RAIL = [\n {label: \"Home\", d: \"m3 7.649-.33.223a.75.75 0 0 1-.84-1.244l7.191-4.852a1.75 1.75 0 0 1 1.958 0l7.19 4.852a.75.75 0 1 1-.838 1.244L17 7.649v7.011c0 2.071-1.679 3.84-3.75 3.84h-6.5C4.679 18.5 3 16.731 3 14.66zM11 11a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1z\"},\n {label: \"DMs\", d: \"M7.675 6.468a4.75 4.75 0 1 1 8.807 3.441.75.75 0 0 0-.067.489l.379 1.896-1.896-.38a.75.75 0 0 0-.489.068 5 5 0 0 1-.648.273.75.75 0 1 0 .478 1.422q.314-.105.611-.242l2.753.55a.75.75 0 0 0 .882-.882l-.55-2.753A6.25 6.25 0 1 0 6.23 6.064a.75.75 0 1 0 1.445.404M6.5 8.5a5 5 0 0 0-4.57 7.03l-.415 2.073a.75.75 0 0 0 .882.882l2.074-.414A5 5 0 1 0 6.5 8.5m-3.5 5a3.5 3.5 0 1 1 1.91 3.119.75.75 0 0 0-.49-.068l-1.214.243.243-1.215a.75.75 0 0 0-.068-.488A3.5 3.5 0 0 1 3 13.5\"},\n {label: \"Activity\", d: \"M9.357 3.256c-.157.177-.31.504-.36 1.062l-.05.558-.55.11c-1.024.204-1.691.71-2.145 1.662-.485 1.016-.736 2.566-.752 4.857l-.002.307-.217.217-2.07 2.077c-.145.164-.193.293-.206.374a.3.3 0 0 0 .034.199c.069.12.304.321.804.321h4.665l.07.672c.034.327.17.668.4.915.214.232.536.413 1.036.413.486 0 .802-.178 1.013-.41.227-.247.362-.588.396-.916l.069-.674h4.663c.5 0 .735-.202.804-.321a.3.3 0 0 0 .034-.199c-.013-.08-.061-.21-.207-.374l-2.068-2.077-.216-.217-.002-.307c-.015-2.291-.265-3.841-.75-4.857-.455-.952-1.123-1.458-2.147-1.663l-.549-.11-.05-.557c-.052-.558-.204-.885-.36-1.062C10.503 3.1 10.31 3 10 3s-.505.1-.643.256m-1.124-.994C8.689 1.746 9.311 1.5 10 1.5s1.31.246 1.767.762c.331.374.54.85.65 1.383 1.21.369 2.104 1.136 2.686 2.357.604 1.266.859 2.989.894 5.185l1.866 1.874.012.012.011.013c.636.7.806 1.59.372 2.342-.406.705-1.223 1.072-2.103 1.072H12.77c-.128.39-.336.775-.638 1.104-.493.538-1.208.896-2.12.896-.917 0-1.638-.356-2.136-.893A3 3 0 0 1 7.23 16.5H3.843c-.88 0-1.697-.367-2.104-1.072-.433-.752-.263-1.642.373-2.342l.011-.013.012-.012 1.869-1.874c.035-2.196.29-3.919.894-5.185.582-1.22 1.475-1.988 2.684-2.357.112-.533.32-1.009.651-1.383\"},\n {label: \"Files\", d: \"M4.836 3A1.836 1.836 0 0 0 3 4.836v7.328c0 .9.646 1.647 1.5 1.805V7.836A3.336 3.336 0 0 1 7.836 4.5h6.133A1.84 1.84 0 0 0 12.164 3zM1.5 12.164a3.337 3.337 0 0 0 3.015 3.32A3.337 3.337 0 0 0 7.836 18.5h3.968c.73 0 1.43-.29 1.945-.805l3.946-3.946a2.75 2.75 0 0 0 .805-1.945V7.836a3.337 3.337 0 0 0-3.015-3.32A3.337 3.337 0 0 0 12.164 1.5H4.836A3.336 3.336 0 0 0 1.5 4.836zM7.836 6A1.836 1.836 0 0 0 6 7.836v7.328C6 16.178 6.822 17 7.836 17H11.5v-4a1.5 1.5 0 0 1 1.5-1.5h4V7.836A1.836 1.836 0 0 0 15.164 6zm8.486 7H13v3.322z\"},\n {label: \"More\", d: \"M14.5 10a1.75 1.75 0 1 1 3.5 0 1.75 1.75 0 0 1-3.5 0m-6.25 0a1.75 1.75 0 1 1 3.5 0 1.75 1.75 0 0 1-3.5 0M2 10a1.75 1.75 0 1 1 3.5 0A1.75 1.75 0 0 1 2 10\"},\n] as const;\n\nconst THEMES = {\n aubergine: {\n chrome: \"#350D36\",\n sidebar: \"#3F0E40\",\n selected: \"#7D3986\",\n sidebarInk: \"rgba(246,228,255,0.9)\",\n selectedInk: \"#EFE1F5\",\n activeRow: \"#F9EDFF\",\n activeRowInk: \"#39063A\",\n page: \"#FFFFFF\",\n ink: \"#1D1C1D\",\n strong: \"#1D1C1D\",\n muted: \"#616061\",\n edge: \"rgba(29,28,29,0.13)\",\n strip: \"#F8F8F8\",\n toolIcon: \"rgba(29,28,29,0.7)\",\n wash: \"rgba(29,28,29,0.06)\",\n badge: \"#DDDDDD\",\n },\n dark: {\n chrome: \"#101214\",\n sidebar: \"#101214\",\n selected: \"#7D3986\",\n sidebarInk: \"rgba(209,210,211,0.9)\",\n selectedInk: \"#EFE1F5\",\n activeRow: \"#1164A3\",\n activeRowInk: \"#FFFFFF\",\n page: \"#1A1D21\",\n ink: \"#D1D2D3\",\n strong: \"#F8F8F8\",\n muted: \"#ABABAD\",\n edge: \"#35373B\",\n strip: \"#222529\",\n toolIcon: \"rgba(209,210,211,0.8)\",\n wash: \"rgba(248,248,248,0.06)\",\n badge: \"#2C2D30\",\n },\n};\n\nconst LINK = \"#1264A3\";\nconst SEND = \"#007A5A\";\n\n/**\n * Slack sets its interface in Lato, and the weights are the recognition as\n * much as the colours: a username is Lato Black, the channel name beside it is\n * only medium, and everything else is regular. Rendering this in the project's\n * own brand sans is what made an accurate layout still read as not-quite\n * Slack. Helvetica Neue is the fallback because it is the closest thing most\n * machines already have; a project that wants it exact loads Lato in its brand.\n */\nconst FONT = '\"Lato\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\n\n/**\n * A Slack channel where an app answers in the thread.\n *\n * The client is drawn whole because the furniture is the recognition: the\n * window bar, the aubergine rail, one channel lit and unread, and a composer\n * with its formatting row and green send. The composer earns its space: a\n * channel with nowhere to type reads as a screenshot of Slack, and a channel\n * with one reads as somebody's Slack.\n *\n * The exchange keeps three beats and never overlaps them: the question lands,\n * the app shows it is working, the answer types itself into the thread.\n */\nexport const Slack = ({\n workspace = \"Odori\",\n channels = [\"general\", \"launches\", \"support\", {name: \"incidents\", private: true}],\n directMessages = [],\n channel = \"support\",\n ask = {\n author: \"John Doe\",\n mention: \"Odori\",\n body: \"the nightly export came out 40MB heavier than yesterday, same commit\",\n time: \"4:46 PM\",\n },\n reaction = {emoji: \"👀\", count: 2},\n reply = {\n author: \"Odori\",\n app: true,\n body: \"Yesterday reused cached chunks. Today re-encoded at studio quality after the toolchain pin changed.\",\n time: \"< 1 minute ago\",\n color: \"#7C3AED\",\n },\n thinkingFrames = 30,\n theme = \"aubergine\",\n charactersPerSecond = 34,\n}: SlackProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const colors = THEMES[theme];\n const px = (value: number) => value * scale;\n\n /**\n * Slack's type ladder, as ratios of its 15px body: a username is the same\n * size as the message and only heavier, the channel name is a fifth larger,\n * and a timestamp is four fifths. Lines are set at 22 over 15.\n */\n const body = 22;\n const heading = Math.round(body * 1.2);\n const small = Math.round(body * 0.8);\n const LINE = 1.467;\n /**\n * Slack's spacing, also as ratios of its 15px body: a 36px avatar, 28px\n * sidebar rows indented 16px, and message rows padded 8 by 20. The client is\n * tighter than it looks in a screenshot, and getting the type right while\n * leaving the spacing loose is what still read as not-quite-Slack.\n */\n const avatarSize = Math.round(body * 2.4);\n const rowHeight = Math.round(body * 1.867);\n const rowRadius = Math.round(body * 0.4);\n const rowIndent = Math.round(body * 1.067);\n const gutterY = Math.round(body * 0.533);\n const gutterX = Math.round(body * 1.333);\n\n /**\n * The typing belongs to the person, not the app.\n *\n * A person composes in the box and presses send; the app answers with a\n * message that is simply there. Typing the app's reply out character by\n * character is the one thing none of these products actually do, and it is\n * what made the earlier version read as a mock-up.\n */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(ask.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 20;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(ask.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const reacted = spring({frame, fps, delayInFrames: askAt + 16, stiffness: 190, damping: 14});\n const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const working = frame >= workingAt && frame < replyAt;\n\n const avatar = (post: SlackPost, size: number) => (\n <span\n style={{\n alignItems: \"center\",\n /* Slack's own offset: the sender's box starts two pixels above the\n top of the picture, so the picture carries the nudge. */\n marginTop: px(2.9),\n background: post.color ?? DEFAULT_AVATAR,\n borderRadius: px(size / 4.5),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(size * 0.52),\n fontWeight: 500,\n height: px(size),\n justifyContent: \"center\",\n width: px(size),\n }}\n >\n {post.author.slice(0, 1).toUpperCase()}\n </span>\n );\n\n /**\n * One slot in the rail, so a hash, a lock, a presence dot and a section's\n * own icon all land on the same left edge. Slack's rail reads as a column\n * because of this, and ours read as a list of indented strings without it.\n */\n const railGlyph = (node: ReactNode) => (\n <span\n style={{\n alignItems: \"center\",\n display: \"flex\",\n flex: \"none\",\n height: px(body),\n justifyContent: \"center\",\n marginRight: px(9),\n width: px(body * 1.067),\n }}\n >\n {node}\n </span>\n );\n\n /** A section heading, set at the row size rather than a smaller one. */\n const section = (label: string, icon: ReactNode) => (\n <div\n style={{\n alignItems: \"center\",\n color: colors.sidebarInk,\n display: \"flex\",\n fontSize: px(body),\n fontWeight: 500,\n height: px(rowHeight),\n margin: `${px(12)}px ${px(10)}px ${px(2)}px`,\n padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,\n }}\n >\n {railGlyph(icon)}\n {label}\n </div>\n );\n\n /** A composer button: 28 square with an 18 icon, as Slack sizes them. */\n const tool = (name: keyof typeof COMPOSER_ICONS, circle = false) => (\n <span\n key={name}\n style={{\n alignItems: \"center\",\n background: circle ? colors.wash : \"transparent\",\n borderRadius: circle ? px(999) : px(6),\n color: colors.toolIcon,\n display: \"flex\",\n flex: \"none\",\n height: px(41),\n justifyContent: \"center\",\n width: px(41),\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(26)} height={px(26)} fill=\"currentColor\">\n {/* Slack's paths carve their counters with the even-odd rule; without\n it the mention icon fills in as a solid disc. */}\n <path clipRule=\"evenodd\" d={COMPOSER_ICONS[name]} fillRule=\"evenodd\" />\n </svg>\n </span>\n );\n\n const glyph = (node: ReactNode, key: string) => (\n <span key={key} style={{alignItems: \"center\", color: colors.toolIcon, display: \"flex\", height: px(41), justifyContent: \"center\", width: px(41)}}>\n {node}\n </span>\n );\n\n const rule = (key: string) => <span key={key} style={{background: colors.edge, height: px(26), margin: `0 ${px(6)}px`, width: px(1)}} />;\n\n return (\n <Fill style={{background: \"#5B2A5E\", padding: px(64)}}>\n <div\n style={{\n borderRadius: px(12),\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n background: colors.chrome,\n display: \"flex\",\n flex: \"none\",\n gap: px(10),\n padding: `${px(16)}px ${px(20)}px`,\n }}\n >\n {[\"#FF5F57\", \"#FEBC2E\", \"#28C840\"].map((light) => (\n <span key={light} style={{background: light, borderRadius: px(999), height: px(14), width: px(14)}} />\n ))}\n </div>\n\n <div style={{display: \"flex\", flex: 1, minHeight: 0}}>\n {/* The rail: the workspace, then the places, each an icon over its\n own label. The name is not here — it is the column's title. */}\n <div style={{alignItems: \"center\", display: \"flex\", flex: \"none\", flexDirection: \"column\", paddingTop: px(12), width: px(103)}}>\n <span\n style={{\n alignItems: \"center\",\n background: colors.selected,\n borderRadius: px(12),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(24),\n fontWeight: 700,\n height: px(53),\n justifyContent: \"center\",\n marginBottom: px(14),\n width: px(53),\n }}\n >\n {workspace.slice(0, 1).toUpperCase()}\n </span>\n {RAIL.map((place, index) => (\n <div\n key={place.label}\n style={{\n alignItems: \"center\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: px(5),\n height: px(100),\n justifyContent: \"center\",\n width: px(76),\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: index === 0 ? \"rgba(249,237,255,0.25)\" : \"transparent\",\n borderRadius: px(12),\n color: index === 0 ? \"#FFFFFF\" : colors.sidebarInk,\n display: \"flex\",\n height: px(53),\n justifyContent: \"center\",\n width: px(53),\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(29)} height={px(29)} fill=\"currentColor\">\n <path clipRule=\"evenodd\" d={place.d} fillRule=\"evenodd\" />\n </svg>\n </span>\n <span\n style={{\n color: index === 0 ? \"#FFFFFF\" : colors.sidebarInk,\n fontSize: px(16),\n fontWeight: 700,\n lineHeight: 1.1,\n }}\n >\n {place.label}\n </span>\n </div>\n ))}\n </div>\n\n <div style={{background: colors.sidebar, flex: \"none\", paddingTop: px(18), width: px(320)}}>\n {/* The workspace name is the column's title, with the chevron\n Slack sets beside it. */}\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(6), padding: `0 ${px(18)}px ${px(16)}px`}}>\n <span style={{color: colors.selectedInk, fontSize: px(heading), fontWeight: 900}}>{workspace}</span>\n <svg viewBox=\"0 0 20 20\" width={px(20)} height={px(20)} fill=\"none\" stroke={colors.selectedInk} strokeWidth={2.2}>\n <path d=\"M6 8l4 4 4-4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </div>\n\n {section(\n \"Channels\",\n <svg viewBox=\"0 0 20 20\" width={px(body * 1.067)} height={px(body * 1.067)} fill=\"currentColor\">\n <path d=\"M14.5 1.75a3.75 3.75 0 0 1 3.75 3.75v9a3.75 3.75 0 0 1-3.75 3.75h-9a3.75 3.75 0 0 1-3.75-3.75v-9A3.75 3.75 0 0 1 5.5 1.75zm-9 1.5A2.25 2.25 0 0 0 3.25 5.5v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25zm3.152 2.215a.751.751 0 0 1 1.478.256l-.268 1.556h1.365l.313-1.81a.75.75 0 0 1 1.478.256l-.269 1.554h1.658a.751.751 0 0 1 0 1.5h-1.915l-.475 2.753H13.8a.75.75 0 0 1 0 1.5h-2.042l-.26 1.503a.75.75 0 0 1-1.478-.255l.215-1.248H8.869l-.26 1.501a.75.75 0 0 1-1.478-.255l.215-1.246H5.593a.75.75 0 0 1 .001-1.5h2.012l.475-2.753H6.2a.75.75 0 0 1 0-1.5h2.14zm.476 6.065h1.366l.474-2.753H9.603z\" />\n </svg>,\n )}\n {channels.map((entry) => {\n const name = typeof entry === \"string\" ? entry : entry.name;\n const locked = typeof entry === \"string\" ? false : Boolean(entry.private);\n const open = name === channel;\n return (\n <div\n key={name}\n style={{\n alignItems: \"center\",\n background: open ? colors.activeRow : \"transparent\",\n borderRadius: px(rowRadius),\n color: open ? colors.activeRowInk : colors.sidebarInk,\n display: \"flex\",\n fontSize: px(body),\n /* Slack does not bolden the channel you are looking at;\n the pill is what marks it. */\n fontWeight: 400,\n height: px(rowHeight),\n margin: `${px(1)}px ${px(10)}px`,\n padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,\n }}\n >\n {railGlyph(\n locked ? (\n <svg viewBox=\"0 0 24 24\" width={px(body * 0.96)} height={px(body * 0.96)} fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <rect x=\"5\" y=\"10.5\" width=\"14\" height=\"9.5\" rx=\"2.2\" />\n <path d=\"M8.5 10.5V7.8a3.5 3.5 0 0 1 7 0v2.7\" strokeLinecap=\"round\" />\n </svg>\n ) : (\n /* The hash Slack draws, with the strokes raked the way a\n real one is. A typed \"#\" sits on the text baseline and\n reads a size too small beside the name. */\n <svg viewBox=\"0 0 24 24\" width={px(body * 1.02)} height={px(body * 1.02)} fill=\"none\" stroke=\"currentColor\" strokeWidth={2.1} strokeLinecap=\"round\">\n <path d=\"M10 4 8 20M17 4l-2 16M4.6 9.5h15M3.4 14.5h15\" />\n </svg>\n ),\n )}\n {name}\n </div>\n );\n })}\n\n {section(\n \"Direct messages\",\n <svg viewBox=\"0 0 20 20\" width={px(body * 1.067)} height={px(body * 1.067)} fill=\"currentColor\">\n <path clipRule=\"evenodd\" fillRule=\"evenodd\" d=\"M7.675 6.468a4.75 4.75 0 1 1 8.807 3.441.75.75 0 0 0-.067.489l.379 1.896-1.896-.38a.75.75 0 0 0-.489.068 5 5 0 0 1-.648.273.75.75 0 1 0 .478 1.422q.314-.105.611-.242l2.753.55a.75.75 0 0 0 .882-.882l-.55-2.753A6.25 6.25 0 1 0 6.23 6.064a.75.75 0 1 0 1.445.404M6.5 8.5a5 5 0 0 0-4.57 7.03l-.415 2.073a.75.75 0 0 0 .882.882l2.074-.414A5 5 0 1 0 6.5 8.5m-3.5 5a3.5 3.5 0 1 1 1.91 3.119.75.75 0 0 0-.49-.068l-1.214.243.243-1.215a.75.75 0 0 0-.068-.488A3.5 3.5 0 0 1 3 13.5\" />\n </svg>,\n )}\n {directMessages.map((name) => (\n <div\n key={name}\n style={{\n alignItems: \"center\",\n color: colors.sidebarInk,\n display: \"flex\",\n fontSize: px(body),\n height: px(rowHeight),\n margin: `${px(1)}px ${px(10)}px`,\n padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,\n }}\n >\n {railGlyph(<span style={{background: \"#20A271\", borderRadius: px(999), display: \"block\", height: px(9), width: px(9)}} />)}\n {name}\n </div>\n ))}\n\n </div>\n\n {/* Slack floats the conversation as its own rounded panel, with the\n workspace colour showing around it. */}\n <div\n style={{\n background: colors.page,\n borderRadius: px(14),\n display: \"flex\",\n flex: 1,\n flexDirection: \"column\",\n margin: `0 ${px(10)}px ${px(10)}px 0`,\n minWidth: 0,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n alignItems: \"center\",\n color: colors.strong,\n display: \"flex\",\n flex: \"none\",\n fontSize: px(heading),\n fontWeight: 900,\n gap: px(3),\n padding: `${px(gutterY * 1.6)}px ${px(gutterX)}px`,\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(heading)} height={px(heading)} fill=\"currentColor\">\n <g transform=\"translate(10 10) scale(1.55) translate(-10 -10)\">\n <path clipRule=\"evenodd\" d={CHANNEL_HASH} fillRule=\"evenodd\" />\n </g>\n </svg>\n {channel}\n </div>\n\n <div style={{flex: 1, minHeight: 0, padding: `${px(gutterY * 2)}px ${px(gutterX)}px 0`}}>\n <div style={{display: \"flex\", gap: px(gutterY), opacity: asked, transform: `translateY(${(1 - asked) * px(8)}px)`}}>\n {avatar(ask, avatarSize)}\n <div style={{minWidth: 0}}>\n {/* The name's cap sits on the avatar's top edge. A loose\n line-height puts half its leading above the cap, which\n drops the name a few pixels and reads as misaligned. */}\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(10), lineHeight: LINE}}>\n <span style={{color: colors.strong, fontSize: px(body), fontWeight: 900}}>{ask.author}</span>\n <span style={{color: colors.muted, fontSize: px(small)}}>{ask.time}</span>\n </div>\n <div style={{color: colors.ink, fontSize: px(body), lineHeight: LINE, marginTop: px(4)}}>\n {ask.mention ? <span style={{color: LINK}}>@{ask.mention} </span> : null}\n {ask.body}\n </div>\n\n {reaction ? (\n <span\n style={{\n alignItems: \"center\",\n display: \"inline-flex\",\n gap: px(8),\n marginTop: px(12),\n opacity: reacted,\n transform: `scale(${0.8 + reacted * 0.2})`,\n }}\n >\n {/* A reaction you are part of is ringed and tinted in\n Slack's blue; one you are not sits on the 6% wash. */}\n <span\n style={{\n alignItems: \"center\",\n background: reaction.mine === false ? colors.wash : REACTED_WASH,\n borderRadius: px(999),\n boxShadow: reaction.mine === false ? undefined : `0 0 0 ${px(1.5)}px ${REACTED_INK}`,\n display: \"inline-flex\",\n gap: px(6),\n height: px(35),\n padding: `0 ${px(12)}px`,\n }}\n >\n <span style={{fontSize: px(26)}}>{reaction.emoji}</span>\n <span\n style={{\n color: reaction.mine === false ? colors.strong : REACTED_INK,\n fontSize: px(18),\n fontWeight: 700,\n }}\n >\n {reaction.count}\n </span>\n </span>\n <span\n style={{\n alignItems: \"center\",\n background: colors.wash,\n borderRadius: px(999),\n color: colors.strong,\n display: \"inline-flex\",\n height: px(35),\n padding: `0 ${px(12)}px`,\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(26)} height={px(26)} fill=\"currentColor\">\n <path clipRule=\"evenodd\" d={ADD_REACTION} fillRule=\"evenodd\" />\n </svg>\n </span>\n </span>\n ) : null}\n </div>\n </div>\n\n {frame >= workingAt ? (\n <>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(16), margin: `${px(20)}px 0 ${px(16)}px`}}>\n <span style={{color: colors.muted, fontSize: px(small), fontWeight: 700}}>1 reply</span>\n <span style={{background: colors.edge, flex: 1, height: px(1)}} />\n </div>\n\n <div\n style={{\n display: \"flex\",\n gap: px(gutterY),\n opacity: working ? 1 : replied,\n transform: `translateY(${(1 - (working ? 1 : replied)) * px(8)}px)`,\n }}\n >\n {avatar(reply, avatarSize)}\n <div style={{minWidth: 0}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10), lineHeight: LINE}}>\n <span style={{color: colors.strong, fontSize: px(body), fontWeight: 900}}>{reply.author}</span>\n <span\n style={{\n background: colors.badge,\n borderRadius: px(4),\n color: colors.muted,\n fontSize: px(Math.round(body * 0.68)),\n fontWeight: 700,\n padding: `${px(2)}px ${px(8)}px`,\n }}\n >\n APP\n </span>\n {frame >= replyAt ? <span style={{color: colors.muted, fontSize: px(small)}}>{reply.time}</span> : null}\n </div>\n <div style={{marginTop: px(4), minHeight: px(36)}}>\n {working ? (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(8), height: px(32)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: colors.muted,\n borderRadius: px(999),\n height: px(10),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(10),\n }}\n />\n ))}\n </span>\n ) : (\n <span style={{color: colors.ink, fontSize: px(body), lineHeight: LINE}}>{reply.body}</span>\n )}\n </div>\n </div>\n </div>\n </>\n ) : null}\n </div>\n\n <div\n style={{\n background: colors.page,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(12),\n flex: \"none\",\n margin: `${px(20)}px ${px(gutterX)}px ${px(gutterY * 2)}px`,\n overflow: \"hidden\",\n }}\n >\n {/* The formatting strip sits on its own tint, and carries the\n set Slack carries: bold, italic, underline, strike, then\n link and the two lists, then quote, code and code block. */}\n <div\n style={{\n alignItems: \"center\",\n background: colors.strip,\n display: \"flex\",\n gap: px(2),\n padding: px(6),\n }}\n >\n {([\"bold\", \"italic\", \"underline\", \"strike\"] as const).map((name) => tool(name))}\n {rule(\"r1\")}\n {([\"link\", \"ordered\", \"bulleted\"] as const).map((name) => tool(name))}\n {rule(\"r2\")}\n {([\"quote\", \"code\", \"codeblock\"] as const).map((name) => tool(name))}\n </div>\n\n <div\n style={{\n color: sent ? colors.muted : colors.ink,\n fontSize: px(body),\n padding: `${px(18)}px ${px(16)}px ${px(24)}px`,\n }}\n >\n {sent ? (\n `Message #${channel}`\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(22),\n marginLeft: px(2),\n transform: `translateY(${px(4)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(2), padding: `0 ${px(10)}px ${px(10)}px`}}>\n {tool(\"attach\", true)}\n {tool(\"format\")}\n {tool(\"emoji\")}\n {tool(\"mention\")}\n {glyph(\n <svg viewBox=\"0 0 20 20\" width={px(26)} height={px(26)} fill=\"currentColor\">\n <circle cx=\"4\" cy=\"10\" r=\"1.6\" />\n <circle cx=\"10\" cy=\"10\" r=\"1.6\" />\n <circle cx=\"16\" cy=\"10\" r=\"1.6\" />\n </svg>,\n \"more\",\n )}\n\n {/* Send is a plain arrow until there is something to send,\n and only then does it turn green. */}\n <span\n style={{\n alignItems: \"center\",\n background: sent ? \"transparent\" : SEND,\n borderRadius: px(6),\n display: \"inline-flex\",\n marginLeft: \"auto\",\n }}\n >\n <span style={{alignItems: \"center\", display: \"flex\", padding: `${px(8)}px ${px(12)}px`}}>\n <svg viewBox=\"0 0 20 20\" width={px(23)} height={px(23)} fill={sent ? colors.muted : \"#FFFFFF\"}>\n <path clipRule=\"evenodd\" d={COMPOSER_ICONS.send} fillRule=\"evenodd\" />\n </svg>\n </span>\n <span\n style={{\n background: sent ? colors.edge : \"rgba(255,255,255,0.35)\",\n height: px(24),\n width: px(1),\n }}\n />\n <span style={{alignItems: \"center\", display: \"flex\", padding: `${px(8)}px ${px(10)}px`}}>\n <svg viewBox=\"0 0 20 20\" width={px(17)} height={px(17)} fill=\"none\" stroke={sent ? colors.muted : \"#FFFFFF\"} strokeWidth={2}>\n <path d=\"M5 8l5 5 5-5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </span>\n </span>\n </div>\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
3404
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\nimport type {ReactNode} from \"react\";\n\nexport type SlackPost = {\n author: string;\n body: string;\n time?: string;\n /** Marks the sender as an app rather than a person. */\n app?: boolean;\n /** The avatar tile colour. */\n color?: string;\n /** A name mentioned at the head of the message, drawn as a link. */\n mention?: string;\n};\n\nexport type SlackProps = {\n workspace?: string;\n /** Channels in the rail. A string is public; an object can be private. */\n channels?: (string | {name: string; private?: boolean})[];\n directMessages?: string[];\n channel?: string;\n /** The message that lands and gets answered. */\n ask?: SlackPost;\n /** A reaction on that message. */\n reaction?: {emoji: string; count: number; mine?: boolean};\n /** The app's reply, typed into the thread under it. */\n reply?: SlackPost;\n thinkingFrames?: number;\n theme?: \"aubergine\" | \"dark\";\n charactersPerSecond?: number;\n};\n\n/**\n * Slack's two looks, measured off the running client.\n *\n * The selected channel is a saturated aubergine that Slack keeps in both\n * themes, not the muted one a screenshot suggests, and the sidebar's text is a\n * lavender at eight tenths rather than a flat grey.\n */\n/**\n * The composer's icons, on Slack's own 20 grid.\n *\n * Drawn by eye these never landed: the set is specific (underline and a\n * separate code block, no camera or microphone) and so is the weight.\n */\nconst COMPOSER_ICONS = {\n bold:\n \"M4 2.75A.75.75 0 0 1 4.75 2h6.343a3.91 3.91 0 0 1 3.88 3.449A2 2 0 0 1 15 5.84l.001.067a3.9 3.9 0 0 1-1.551 3.118A4.627 4.627 0 0 1 11.875 18H4.75a.75.75 0 0 1-.75-.75V9.5a.8.8 0 0 1 .032-.218A.8.8 0 0 1 4 9.065zm2.5 5.565h3.593a2.157 2.157 0 1 0 0-4.315H6.5zm4.25 1.935H6.5v5.5h4.25a2.75 2.75 0 1 0 0-5.5\",\n italic:\n \"M7 2.75A.75.75 0 0 1 7.75 2h7.5a.75.75 0 0 1 0 1.5H12.3l-2.6 13h2.55a.75.75 0 0 1 0 1.5h-7.5a.75.75 0 0 1 0-1.5H7.7l2.6-13H7.75A.75.75 0 0 1 7 2.75\",\n underline:\n \"M17.25 17.12a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5zM14.5 1.63a.75.75 0 0 1 .75.75v8a5.25 5.25 0 1 1-10.5 0v-8a.75.75 0 0 1 1.5 0v8a3.75 3.75 0 0 0 7.5 0v-8a.75.75 0 0 1 .75-.75\",\n strike:\n \"M11.721 3.84c-.91-.334-2.028-.36-3.035-.114-1.51.407-2.379 1.861-2.164 3.15C6.718 8.051 7.939 9.5 11.5 9.5l.027.001h5.723a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5h3.66c-.76-.649-1.216-1.468-1.368-2.377-.347-2.084 1.033-4.253 3.265-4.848l.007-.002.007-.002c1.252-.307 2.68-.292 3.915.16 1.252.457 2.337 1.381 2.738 2.874a.75.75 0 0 1-1.448.39c-.25-.925-.91-1.528-1.805-1.856m2.968 9.114a.75.75 0 1 0-1.378.59c.273.64.186 1.205-.13 1.674-.333.492-.958.925-1.82 1.137-.989.243-1.991.165-3.029-.124-.93-.26-1.613-.935-1.858-1.845a.75.75 0 0 0-1.448.39c.388 1.441 1.483 2.503 2.903 2.9 1.213.338 2.486.456 3.79.135 1.14-.28 2.12-.889 2.704-1.753.6-.888.743-1.992.266-3.104\",\n link:\n \"M12.306 3.756a2.75 2.75 0 0 1 3.889 0l.05.05a2.75 2.75 0 0 1 0 3.889l-3.18 3.18a2.75 2.75 0 0 1-3.98-.095l-.03-.034a.75.75 0 0 0-1.11 1.009l.03.034a4.25 4.25 0 0 0 6.15.146l3.18-3.18a4.25 4.25 0 0 0 0-6.01l-.05-.05a4.25 4.25 0 0 0-6.01 0L9.47 4.47a.75.75 0 1 0 1.06 1.06zm-4.611 12.49a2.75 2.75 0 0 1-3.89 0l-.05-.051a2.75 2.75 0 0 1 0-3.89l3.18-3.179a2.75 2.75 0 0 1 3.98.095l.03.034a.75.75 0 1 0 1.11-1.01l-.03-.033a4.25 4.25 0 0 0-6.15-.146l-3.18 3.18a4.25 4.25 0 0 0 0 6.01l.05.05a4.25 4.25 0 0 0 6.01 0l1.775-1.775a.75.75 0 0 0-1.06-1.06z\",\n ordered:\n \"M3.792 2.094A.5.5 0 0 1 4 2.5V6h1a.5.5 0 1 1 0 1H2a.5.5 0 1 1 0-1h1V3.194l-.842.28a.5.5 0 0 1-.316-.948l1.5-.5a.5.5 0 0 1 .45.068M7.75 3.5a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zM7 10.75a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10a.75.75 0 0 1-.75-.75m0 6.5a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10a.75.75 0 0 1-.75-.75m-4.293-3.36a1 1 0 0 1 .793-.39c.49 0 .75.38.75.75 0 .064-.033.194-.173.409a5 5 0 0 1-.594.711c-.256.267-.552.548-.87.848l-.088.084a42 42 0 0 0-.879.845A.5.5 0 0 0 2 18h3a.5.5 0 0 0 0-1H3.242l.058-.055c.316-.298.629-.595.904-.882a6 6 0 0 0 .711-.859c.18-.277.335-.604.335-.954 0-.787-.582-1.75-1.75-1.75a2 2 0 0 0-1.81 1.147.5.5 0 1 0 .905.427 1 1 0 0 1 .112-.184\",\n bulleted:\n \"M4 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10A.75.75 0 0 1 7 3m.75 6.25a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zm0 7a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zM3 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2m0 7a1 1 0 1 0 0-2 1 1 0 0 0 0 2\",\n quote:\n \"M3.5 2.75a.75.75 0 0 0-1.5 0v14.5a.75.75 0 0 0 1.5 0zM6.75 3a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5zM6 10.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H6.75a.75.75 0 0 1-.75-.75m.75 5.25a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5z\",\n code:\n \"M12.058 3.212c.396.12.62.54.5.936L8.87 16.29a.75.75 0 1 1-1.435-.436l3.686-12.143a.75.75 0 0 1 .936-.5M5.472 6.24a.75.75 0 0 1 .005 1.06l-2.67 2.693 2.67 2.691a.75.75 0 1 1-1.065 1.057l-3.194-3.22a.75.75 0 0 1 0-1.056l3.194-3.22a.75.75 0 0 1 1.06-.005m9.044 1.06a.75.75 0 1 1 1.065-1.056l3.194 3.221a.75.75 0 0 1 0 1.057l-3.194 3.219a.75.75 0 0 1-1.065-1.057l2.67-2.69z\",\n codeblock:\n \"M9.212 2.737a.75.75 0 1 0-1.424-.474l-2.5 7.5a.75.75 0 0 0 1.424.474zm6.038.265a.75.75 0 0 0 0 1.5h2a.25.25 0 0 1 .25.25v11.5a.25.25 0 0 1-.25.25h-13a.25.25 0 0 1-.25-.25v-3.5a.75.75 0 0 0-1.5 0v3.5c0 .966.784 1.75 1.75 1.75h13a1.75 1.75 0 0 0 1.75-1.75v-11.5a1.75 1.75 0 0 0-1.75-1.75zm-3.69.5a.75.75 0 1 0-1.12.996l1.556 1.754-1.556 1.75a.75.75 0 1 0 1.12.997l2-2.249a.75.75 0 0 0 0-.996zM3.999 9.061a.75.75 0 0 1-1.058-.062l-2-2.249a.75.75 0 0 1 0-.996l2-2.252a.75.75 0 1 1 1.12.996L2.504 6.252l1.557 1.75a.75.75 0 0 1-.062 1.059\",\n attach:\n \"M10.75 3.25a.75.75 0 0 0-1.5 0v6H3.251L3.25 10v-.75a.75.75 0 0 0 0 1.5V10v.75h6v6a.75.75 0 0 0 1.5 0v-6h6a.75.75 0 0 0 0-1.5h-6z\",\n format:\n \"M6.941 3.952c-.459-1.378-2.414-1.363-2.853.022l-4.053 12.8a.75.75 0 0 0 1.43.452l1.101-3.476h6.06l1.163 3.487a.75.75 0 1 0 1.423-.474zm1.185 8.298L5.518 4.427 3.041 12.25zm6.198-5.537a4.74 4.74 0 0 1 3.037-.081A3.74 3.74 0 0 1 20 10.208V17a.75.75 0 0 1-1.5 0v-.745a8 8 0 0 1-2.847 1.355 3 3 0 0 1-3.15-1.143C10.848 14.192 12.473 11 15.287 11H18.5v-.792c0-.984-.641-1.853-1.581-2.143a3.24 3.24 0 0 0-2.077.056l-.242.089a2.22 2.22 0 0 0-1.34 1.382l-.048.145a.75.75 0 0 1-1.423-.474l.048-.145a3.72 3.72 0 0 1 2.244-2.315zM18.5 12.5h-3.213c-1.587 0-2.504 1.801-1.57 3.085.357.491.98.717 1.572.57a6.5 6.5 0 0 0 2.47-1.223l.741-.593z\",\n emoji:\n \"M2.5 10a7.5 7.5 0 1 1 15 0 7.5 7.5 0 0 1-15 0M10 1a9 9 0 1 0 0 18 9 9 0 0 0 0-18M7.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3M14 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m-6.385 3.766a.75.75 0 1 0-1.425.468C6.796 14.08 8.428 15 10.027 15s3.23-.92 3.838-2.766a.75.75 0 1 0-1.425-.468c-.38 1.155-1.38 1.734-2.413 1.734s-2.032-.58-2.412-1.734\",\n mention:\n \"M2.5 10a7.5 7.5 0 1 1 15 0v.645c0 1.024-.83 1.855-1.855 1.855a1.145 1.145 0 0 1-1.145-1.145V6.75a.75.75 0 0 0-1.494-.098 4.5 4.5 0 1 0 .465 6.212A2.64 2.64 0 0 0 15.646 14 3.355 3.355 0 0 0 19 10.645V10a9 9 0 1 0-3.815 7.357.75.75 0 1 0-.865-1.225A7.5 7.5 0 0 1 2.5 10m7.5 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6\",\n send:\n \"M1.5 2.106c0-.462.498-.754.901-.528l15.7 7.714a.73.73 0 0 1 .006 1.307L2.501 18.46l-.07.017a.754.754 0 0 1-.931-.733v-4.572c0-1.22.971-2.246 2.213-2.268l6.547-.17c.27-.01.75-.243.75-.797 0-.553-.5-.795-.75-.795l-6.547-.171C2.47 8.95 1.5 7.924 1.5 6.704z\",\n} as const;\n\n/**\n * The channel hash, taken from inside Slack's own channels mark and scaled\n * to fill the grid. Typing a \"#\" gives the font's hash, which is upright\n * where Slack's is raked, and lands a size small beside the name.\n */\nconst CHANNEL_HASH =\n \"M8.652 5.465a.751.751 0 0 1 1.478.256l-.268 1.556h1.365l.313-1.81a.75.75 0 0 1 1.478.256l-.269 1.554h1.658a.751.751 0 0 1 0 1.5h-1.915l-.475 2.753H13.8a.75.75 0 0 1 0 1.5h-2.042l-.26 1.503a.75.75 0 0 1-1.478-.255l.215-1.248H8.869l-.26 1.501a.75.75 0 0 1-1.478-.255l.215-1.246H5.593a.75.75 0 0 1 .001-1.5h2.012l.475-2.753H6.2a.75.75 0 0 1 0-1.5h2.14zM9.128 11.53h1.366l.474-2.753H9.603z\";\n\n/**\n * The add-reaction control's face. Slack layers a solid disc beneath this\n * one; filling both gives a blob, and this path already carries the ring,\n * the plus, the eyes and the mouth.\n */\nconst ADD_REACTION =\n \"M15.5 1a.75.75 0 0 1 .75.75v2h2a.75.75 0 0 1 0 1.5h-2v2a.75.75 0 0 1-1.5 0v-2h-2a.75.75 0 0 1 0-1.5h2v-2A.75.75 0 0 1 15.5 1Zm-13 10a6.5 6.5 0 0 1 7.166-6.466.75.75 0 0 0 .152-1.493 8 8 0 1 0 7.14 7.139.75.75 0 0 0-1.492.152A6.525 6.525 0 0 1 15.5 11a6.5 6.5 0 1 1-13 0Zm4.25-.5a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm4.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9 15c1.277 0 2.553-.724 3.06-2.173.148-.426-.209-.827-.66-.827H6.6c-.452 0-.808.4-.66.827C6.448 14.276 7.724 15 9 15Z\";\n\n/** Slack's blue for a reaction you are part of: the ring and the ink. */\n/**\n * Slack's default picture is an initial on a flat ground in a rounded\n * square, at a shade over half the box and in a medium rather than a bold.\n */\nconst DEFAULT_AVATAR = \"#2B5C8A\";\n\nconst REACTED_WASH = \"#E3F8FF\";\nconst REACTED_INK = \"#1264A3\";\n\n/**\n * The rail Slack puts left of everything: the workspace, then Home, DMs,\n * Activity, Files and More, each an icon over its own label. Slack's own\n * paths, on its 20 grid.\n */\nconst RAIL = [\n {label: \"Home\", d: \"m3 7.649-.33.223a.75.75 0 0 1-.84-1.244l7.191-4.852a1.75 1.75 0 0 1 1.958 0l7.19 4.852a.75.75 0 1 1-.838 1.244L17 7.649v7.011c0 2.071-1.679 3.84-3.75 3.84h-6.5C4.679 18.5 3 16.731 3 14.66zM11 11a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1z\"},\n {label: \"DMs\", d: \"M7.675 6.468a4.75 4.75 0 1 1 8.807 3.441.75.75 0 0 0-.067.489l.379 1.896-1.896-.38a.75.75 0 0 0-.489.068 5 5 0 0 1-.648.273.75.75 0 1 0 .478 1.422q.314-.105.611-.242l2.753.55a.75.75 0 0 0 .882-.882l-.55-2.753A6.25 6.25 0 1 0 6.23 6.064a.75.75 0 1 0 1.445.404M6.5 8.5a5 5 0 0 0-4.57 7.03l-.415 2.073a.75.75 0 0 0 .882.882l2.074-.414A5 5 0 1 0 6.5 8.5m-3.5 5a3.5 3.5 0 1 1 1.91 3.119.75.75 0 0 0-.49-.068l-1.214.243.243-1.215a.75.75 0 0 0-.068-.488A3.5 3.5 0 0 1 3 13.5\"},\n {label: \"Activity\", d: \"M9.357 3.256c-.157.177-.31.504-.36 1.062l-.05.558-.55.11c-1.024.204-1.691.71-2.145 1.662-.485 1.016-.736 2.566-.752 4.857l-.002.307-.217.217-2.07 2.077c-.145.164-.193.293-.206.374a.3.3 0 0 0 .034.199c.069.12.304.321.804.321h4.665l.07.672c.034.327.17.668.4.915.214.232.536.413 1.036.413.486 0 .802-.178 1.013-.41.227-.247.362-.588.396-.916l.069-.674h4.663c.5 0 .735-.202.804-.321a.3.3 0 0 0 .034-.199c-.013-.08-.061-.21-.207-.374l-2.068-2.077-.216-.217-.002-.307c-.015-2.291-.265-3.841-.75-4.857-.455-.952-1.123-1.458-2.147-1.663l-.549-.11-.05-.557c-.052-.558-.204-.885-.36-1.062C10.503 3.1 10.31 3 10 3s-.505.1-.643.256m-1.124-.994C8.689 1.746 9.311 1.5 10 1.5s1.31.246 1.767.762c.331.374.54.85.65 1.383 1.21.369 2.104 1.136 2.686 2.357.604 1.266.859 2.989.894 5.185l1.866 1.874.012.012.011.013c.636.7.806 1.59.372 2.342-.406.705-1.223 1.072-2.103 1.072H12.77c-.128.39-.336.775-.638 1.104-.493.538-1.208.896-2.12.896-.917 0-1.638-.356-2.136-.893A3 3 0 0 1 7.23 16.5H3.843c-.88 0-1.697-.367-2.104-1.072-.433-.752-.263-1.642.373-2.342l.011-.013.012-.012 1.869-1.874c.035-2.196.29-3.919.894-5.185.582-1.22 1.475-1.988 2.684-2.357.112-.533.32-1.009.651-1.383\"},\n {label: \"Files\", d: \"M4.836 3A1.836 1.836 0 0 0 3 4.836v7.328c0 .9.646 1.647 1.5 1.805V7.836A3.336 3.336 0 0 1 7.836 4.5h6.133A1.84 1.84 0 0 0 12.164 3zM1.5 12.164a3.337 3.337 0 0 0 3.015 3.32A3.337 3.337 0 0 0 7.836 18.5h3.968c.73 0 1.43-.29 1.945-.805l3.946-3.946a2.75 2.75 0 0 0 .805-1.945V7.836a3.337 3.337 0 0 0-3.015-3.32A3.337 3.337 0 0 0 12.164 1.5H4.836A3.336 3.336 0 0 0 1.5 4.836zM7.836 6A1.836 1.836 0 0 0 6 7.836v7.328C6 16.178 6.822 17 7.836 17H11.5v-4a1.5 1.5 0 0 1 1.5-1.5h4V7.836A1.836 1.836 0 0 0 15.164 6zm8.486 7H13v3.322z\"},\n {label: \"More\", d: \"M14.5 10a1.75 1.75 0 1 1 3.5 0 1.75 1.75 0 0 1-3.5 0m-6.25 0a1.75 1.75 0 1 1 3.5 0 1.75 1.75 0 0 1-3.5 0M2 10a1.75 1.75 0 1 1 3.5 0A1.75 1.75 0 0 1 2 10\"},\n] as const;\n\nconst THEMES = {\n aubergine: {\n chrome: \"#350D36\",\n sidebar: \"#3F0E40\",\n selected: \"#7D3986\",\n sidebarInk: \"rgba(246,228,255,0.9)\",\n selectedInk: \"#EFE1F5\",\n activeRow: \"#F9EDFF\",\n activeRowInk: \"#39063A\",\n page: \"#FFFFFF\",\n ink: \"#1D1C1D\",\n strong: \"#1D1C1D\",\n muted: \"#616061\",\n edge: \"rgba(29,28,29,0.13)\",\n strip: \"#F8F8F8\",\n toolIcon: \"rgba(29,28,29,0.7)\",\n wash: \"rgba(29,28,29,0.06)\",\n badge: \"#DDDDDD\",\n },\n dark: {\n chrome: \"#101214\",\n sidebar: \"#101214\",\n selected: \"#7D3986\",\n sidebarInk: \"rgba(209,210,211,0.9)\",\n selectedInk: \"#EFE1F5\",\n activeRow: \"#1164A3\",\n activeRowInk: \"#FFFFFF\",\n page: \"#1A1D21\",\n ink: \"#D1D2D3\",\n strong: \"#F8F8F8\",\n muted: \"#ABABAD\",\n edge: \"#35373B\",\n strip: \"#222529\",\n toolIcon: \"rgba(209,210,211,0.8)\",\n wash: \"rgba(248,248,248,0.06)\",\n badge: \"#2C2D30\",\n },\n};\n\nconst LINK = \"#1264A3\";\nconst SEND = \"#007A5A\";\n\n/**\n * Slack sets its interface in Lato, and the weights are the recognition as\n * much as the colours: a username is Lato Black, the channel name beside it is\n * only medium, and everything else is regular. Rendering this in the project's\n * own brand sans is what made an accurate layout still read as not-quite\n * Slack. Helvetica Neue is the fallback because it is the closest thing most\n * machines already have; a project that wants it exact loads Lato in its brand.\n */\nconst FONT = '\"Lato\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\n\n/**\n * A Slack channel where an app answers in the thread.\n *\n * The client is drawn whole because the furniture is the recognition: the\n * window bar, the aubergine rail, one channel lit and unread, and a composer\n * with its formatting row and green send. The composer earns its space: a\n * channel with nowhere to type reads as a screenshot of Slack, and a channel\n * with one reads as somebody's Slack.\n *\n * The exchange keeps three beats and never overlaps them: the question lands,\n * the app shows it is working, the answer types itself into the thread.\n */\nexport const Slack = ({\n workspace = \"Odori\",\n channels = [\"general\", \"launches\", \"support\", {name: \"incidents\", private: true}],\n directMessages = [],\n channel = \"support\",\n ask = {\n author: \"John Doe\",\n mention: \"Odori\",\n body: \"the nightly export came out 40MB heavier than yesterday, same commit\",\n time: \"4:46 PM\",\n },\n reaction = {emoji: \"👀\", count: 2},\n reply = {\n author: \"Odori\",\n app: true,\n body: \"Yesterday reused cached chunks. Today re-encoded at studio quality after the toolchain pin changed.\",\n time: \"< 1 minute ago\",\n color: \"#7C3AED\",\n },\n thinkingFrames = 30,\n theme = \"aubergine\",\n charactersPerSecond = 34,\n}: SlackProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const scale = useDesignScale();\n const colors = THEMES[theme];\n const px = (value: number) => value * scale;\n\n /**\n * Slack's type ladder, as ratios of its 15px body: a username is the same\n * size as the message and only heavier, the channel name is a fifth larger,\n * and a timestamp is four fifths. Lines are set at 22 over 15.\n */\n const body = 22;\n const heading = Math.round(body * 1.2);\n const small = Math.round(body * 0.8);\n const LINE = 1.467;\n /**\n * Slack's spacing, also as ratios of its 15px body: a 36px avatar, 28px\n * sidebar rows indented 16px, and message rows padded 8 by 20. The client is\n * tighter than it looks in a screenshot, and getting the type right while\n * leaving the spacing loose is what still read as not-quite-Slack.\n */\n const avatarSize = Math.round(body * 2.4);\n const rowHeight = Math.round(body * 1.867);\n const rowRadius = Math.round(body * 0.4);\n const rowIndent = Math.round(body * 1.067);\n const gutterY = Math.round(body * 0.533);\n const gutterX = Math.round(body * 1.333);\n\n /**\n * The typing belongs to the person, not the app.\n *\n * A person composes in the box and presses send; the app answers with a\n * message that is simply there. Typing the app's reply out character by\n * character is the one thing none of these products actually do, and it is\n * what made the earlier version read as a mock-up.\n */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(ask.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 20;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(ask.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const reacted = spring({frame, fps, delayInFrames: askAt + 16, stiffness: 190, damping: 14});\n const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const working = frame >= workingAt && frame < replyAt;\n\n const avatar = (post: SlackPost, size: number) => (\n <span\n style={{\n alignItems: \"center\",\n /* Slack's own offset: the sender's box starts two pixels above the\n top of the picture, so the picture carries the nudge. */\n marginTop: px(2.9),\n background: post.color ?? DEFAULT_AVATAR,\n borderRadius: px(size / 4.5),\n color: \"#FFFFFF\",\n display: \"flex\",\n flex: \"none\",\n fontSize: px(size * 0.52),\n fontWeight: 500,\n height: px(size),\n justifyContent: \"center\",\n width: px(size),\n }}\n >\n {post.author.slice(0, 1).toUpperCase()}\n </span>\n );\n\n /**\n * One slot in the rail, so a hash, a lock, a presence dot and a section's\n * own icon all land on the same left edge. Slack's rail reads as a column\n * because of this, and ours read as a list of indented strings without it.\n */\n const railGlyph = (node: ReactNode) => (\n <span\n style={{\n alignItems: \"center\",\n display: \"flex\",\n flex: \"none\",\n height: px(body),\n justifyContent: \"center\",\n marginRight: px(9),\n width: px(body * 1.067),\n }}\n >\n {node}\n </span>\n );\n\n /** A section heading, set at the row size rather than a smaller one. */\n const section = (label: string, icon: ReactNode) => (\n <div\n style={{\n alignItems: \"center\",\n color: colors.sidebarInk,\n display: \"flex\",\n fontSize: px(body),\n fontWeight: 500,\n height: px(rowHeight),\n margin: `${px(12)}px ${px(10)}px ${px(2)}px`,\n padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,\n }}\n >\n {railGlyph(icon)}\n {label}\n </div>\n );\n\n /** A composer button: 28 square with an 18 icon, as Slack sizes them. */\n const tool = (name: keyof typeof COMPOSER_ICONS, circle = false) => (\n <span\n key={name}\n style={{\n alignItems: \"center\",\n background: circle ? colors.wash : \"transparent\",\n borderRadius: circle ? px(999) : px(6),\n color: colors.toolIcon,\n display: \"flex\",\n flex: \"none\",\n height: px(41),\n justifyContent: \"center\",\n width: px(41),\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(26)} height={px(26)} fill=\"currentColor\">\n {/* Slack's paths carve their counters with the even-odd rule; without\n it the mention icon fills in as a solid disc. */}\n <path clipRule=\"evenodd\" d={COMPOSER_ICONS[name]} fillRule=\"evenodd\" />\n </svg>\n </span>\n );\n\n const glyph = (node: ReactNode, key: string) => (\n <span key={key} style={{alignItems: \"center\", color: colors.toolIcon, display: \"flex\", height: px(41), justifyContent: \"center\", width: px(41)}}>\n {node}\n </span>\n );\n\n const rule = (key: string) => <span key={key} style={{background: colors.edge, height: px(26), margin: `0 ${px(6)}px`, width: px(1)}} />;\n\n return (\n <Fill style={{background: \"#5B2A5E\", padding: px(64)}}>\n <div\n style={{\n borderRadius: px(12),\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n background: colors.chrome,\n display: \"flex\",\n flex: \"none\",\n gap: px(10),\n padding: `${px(16)}px ${px(20)}px`,\n }}\n >\n {[\"#FF5F57\", \"#FEBC2E\", \"#28C840\"].map((light) => (\n <span key={light} style={{background: light, borderRadius: px(999), height: px(14), width: px(14)}} />\n ))}\n </div>\n\n <div style={{display: \"flex\", flex: 1, minHeight: 0}}>\n {/* The rail: the workspace, then the places, each an icon over its\n own label. The name is not here — it is the column's title. */}\n <div style={{alignItems: \"center\", display: \"flex\", flex: \"none\", flexDirection: \"column\", paddingTop: px(12), width: px(103)}}>\n <span\n style={{\n alignItems: \"center\",\n background: colors.selected,\n borderRadius: px(12),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(24),\n fontWeight: 700,\n height: px(53),\n justifyContent: \"center\",\n marginBottom: px(14),\n width: px(53),\n }}\n >\n {workspace.slice(0, 1).toUpperCase()}\n </span>\n {RAIL.map((place, index) => (\n <div\n key={place.label}\n /* Furniture. Slack's rail labels really are this small, and\n growing them until they clear the readability floor draws a\n Slack nobody recognises. Nothing here is content. */\n data-odori-chrome\n style={{\n alignItems: \"center\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: px(5),\n height: px(100),\n justifyContent: \"center\",\n width: px(76),\n }}\n >\n <span\n style={{\n alignItems: \"center\",\n background: index === 0 ? \"rgba(249,237,255,0.25)\" : \"transparent\",\n borderRadius: px(12),\n color: index === 0 ? \"#FFFFFF\" : colors.sidebarInk,\n display: \"flex\",\n height: px(53),\n justifyContent: \"center\",\n width: px(53),\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(29)} height={px(29)} fill=\"currentColor\">\n <path clipRule=\"evenodd\" d={place.d} fillRule=\"evenodd\" />\n </svg>\n </span>\n <span\n style={{\n color: index === 0 ? \"#FFFFFF\" : colors.sidebarInk,\n fontSize: px(16),\n fontWeight: 700,\n lineHeight: 1.1,\n }}\n >\n {place.label}\n </span>\n </div>\n ))}\n </div>\n\n <div style={{background: colors.sidebar, flex: \"none\", paddingTop: px(18), width: px(320)}}>\n {/* The workspace name is the column's title, with the chevron\n Slack sets beside it. */}\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(6), padding: `0 ${px(18)}px ${px(16)}px`}}>\n <span style={{color: colors.selectedInk, fontSize: px(heading), fontWeight: 900}}>{workspace}</span>\n <svg viewBox=\"0 0 20 20\" width={px(20)} height={px(20)} fill=\"none\" stroke={colors.selectedInk} strokeWidth={2.2}>\n <path d=\"M6 8l4 4 4-4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </div>\n\n {section(\n \"Channels\",\n <svg viewBox=\"0 0 20 20\" width={px(body * 1.067)} height={px(body * 1.067)} fill=\"currentColor\">\n <path d=\"M14.5 1.75a3.75 3.75 0 0 1 3.75 3.75v9a3.75 3.75 0 0 1-3.75 3.75h-9a3.75 3.75 0 0 1-3.75-3.75v-9A3.75 3.75 0 0 1 5.5 1.75zm-9 1.5A2.25 2.25 0 0 0 3.25 5.5v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25zm3.152 2.215a.751.751 0 0 1 1.478.256l-.268 1.556h1.365l.313-1.81a.75.75 0 0 1 1.478.256l-.269 1.554h1.658a.751.751 0 0 1 0 1.5h-1.915l-.475 2.753H13.8a.75.75 0 0 1 0 1.5h-2.042l-.26 1.503a.75.75 0 0 1-1.478-.255l.215-1.248H8.869l-.26 1.501a.75.75 0 0 1-1.478-.255l.215-1.246H5.593a.75.75 0 0 1 .001-1.5h2.012l.475-2.753H6.2a.75.75 0 0 1 0-1.5h2.14zm.476 6.065h1.366l.474-2.753H9.603z\" />\n </svg>,\n )}\n {channels.map((entry) => {\n const name = typeof entry === \"string\" ? entry : entry.name;\n const locked = typeof entry === \"string\" ? false : Boolean(entry.private);\n const open = name === channel;\n return (\n <div\n key={name}\n style={{\n alignItems: \"center\",\n background: open ? colors.activeRow : \"transparent\",\n borderRadius: px(rowRadius),\n color: open ? colors.activeRowInk : colors.sidebarInk,\n display: \"flex\",\n fontSize: px(body),\n /* Slack does not bolden the channel you are looking at;\n the pill is what marks it. */\n fontWeight: 400,\n height: px(rowHeight),\n margin: `${px(1)}px ${px(10)}px`,\n padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,\n }}\n >\n {railGlyph(\n locked ? (\n <svg viewBox=\"0 0 24 24\" width={px(body * 0.96)} height={px(body * 0.96)} fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <rect x=\"5\" y=\"10.5\" width=\"14\" height=\"9.5\" rx=\"2.2\" />\n <path d=\"M8.5 10.5V7.8a3.5 3.5 0 0 1 7 0v2.7\" strokeLinecap=\"round\" />\n </svg>\n ) : (\n /* The hash Slack draws, with the strokes raked the way a\n real one is. A typed \"#\" sits on the text baseline and\n reads a size too small beside the name. */\n <svg viewBox=\"0 0 24 24\" width={px(body * 1.02)} height={px(body * 1.02)} fill=\"none\" stroke=\"currentColor\" strokeWidth={2.1} strokeLinecap=\"round\">\n <path d=\"M10 4 8 20M17 4l-2 16M4.6 9.5h15M3.4 14.5h15\" />\n </svg>\n ),\n )}\n {name}\n </div>\n );\n })}\n\n {section(\n \"Direct messages\",\n <svg viewBox=\"0 0 20 20\" width={px(body * 1.067)} height={px(body * 1.067)} fill=\"currentColor\">\n <path clipRule=\"evenodd\" fillRule=\"evenodd\" d=\"M7.675 6.468a4.75 4.75 0 1 1 8.807 3.441.75.75 0 0 0-.067.489l.379 1.896-1.896-.38a.75.75 0 0 0-.489.068 5 5 0 0 1-.648.273.75.75 0 1 0 .478 1.422q.314-.105.611-.242l2.753.55a.75.75 0 0 0 .882-.882l-.55-2.753A6.25 6.25 0 1 0 6.23 6.064a.75.75 0 1 0 1.445.404M6.5 8.5a5 5 0 0 0-4.57 7.03l-.415 2.073a.75.75 0 0 0 .882.882l2.074-.414A5 5 0 1 0 6.5 8.5m-3.5 5a3.5 3.5 0 1 1 1.91 3.119.75.75 0 0 0-.49-.068l-1.214.243.243-1.215a.75.75 0 0 0-.068-.488A3.5 3.5 0 0 1 3 13.5\" />\n </svg>,\n )}\n {directMessages.map((name) => (\n <div\n key={name}\n style={{\n alignItems: \"center\",\n color: colors.sidebarInk,\n display: \"flex\",\n fontSize: px(body),\n height: px(rowHeight),\n margin: `${px(1)}px ${px(10)}px`,\n padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,\n }}\n >\n {railGlyph(<span style={{background: \"#20A271\", borderRadius: px(999), display: \"block\", height: px(9), width: px(9)}} />)}\n {name}\n </div>\n ))}\n\n </div>\n\n {/* Slack floats the conversation as its own rounded panel, with the\n workspace colour showing around it. */}\n <div\n style={{\n background: colors.page,\n borderRadius: px(14),\n display: \"flex\",\n flex: 1,\n flexDirection: \"column\",\n margin: `0 ${px(10)}px ${px(10)}px 0`,\n minWidth: 0,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n borderBottom: `${px(1)}px solid ${colors.edge}`,\n alignItems: \"center\",\n color: colors.strong,\n display: \"flex\",\n flex: \"none\",\n fontSize: px(heading),\n fontWeight: 900,\n gap: px(3),\n padding: `${px(gutterY * 1.6)}px ${px(gutterX)}px`,\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(heading)} height={px(heading)} fill=\"currentColor\">\n <g transform=\"translate(10 10) scale(1.55) translate(-10 -10)\">\n <path clipRule=\"evenodd\" d={CHANNEL_HASH} fillRule=\"evenodd\" />\n </g>\n </svg>\n {channel}\n </div>\n\n <div style={{flex: 1, minHeight: 0, padding: `${px(gutterY * 2)}px ${px(gutterX)}px 0`}}>\n <div style={{display: \"flex\", gap: px(gutterY), opacity: asked, transform: `translateY(${(1 - asked) * px(8)}px)`}}>\n {avatar(ask, avatarSize)}\n <div style={{minWidth: 0}}>\n {/* The name's cap sits on the avatar's top edge. A loose\n line-height puts half its leading above the cap, which\n drops the name a few pixels and reads as misaligned. */}\n <div style={{alignItems: \"baseline\", display: \"flex\", gap: px(10), lineHeight: LINE}}>\n <span style={{color: colors.strong, fontSize: px(body), fontWeight: 900}}>{ask.author}</span>\n <span data-odori-chrome style={{color: colors.muted, fontSize: px(small)}}>{ask.time}</span>\n </div>\n <div style={{color: colors.ink, fontSize: px(body), lineHeight: LINE, marginTop: px(4)}}>\n {ask.mention ? <span style={{color: LINK}}>@{ask.mention} </span> : null}\n {ask.body}\n </div>\n\n {reaction ? (\n <span\n style={{\n alignItems: \"center\",\n display: \"inline-flex\",\n gap: px(8),\n marginTop: px(12),\n opacity: reacted,\n transform: `scale(${0.8 + reacted * 0.2})`,\n }}\n >\n {/* A reaction you are part of is ringed and tinted in\n Slack's blue; one you are not sits on the 6% wash. */}\n <span\n style={{\n alignItems: \"center\",\n background: reaction.mine === false ? colors.wash : REACTED_WASH,\n borderRadius: px(999),\n boxShadow: reaction.mine === false ? undefined : `0 0 0 ${px(1.5)}px ${REACTED_INK}`,\n display: \"inline-flex\",\n gap: px(6),\n height: px(35),\n padding: `0 ${px(12)}px`,\n }}\n >\n <span style={{fontSize: px(26)}}>{reaction.emoji}</span>\n <span\n data-odori-chrome\n style={{\n color: reaction.mine === false ? colors.strong : REACTED_INK,\n fontSize: px(18),\n fontWeight: 700,\n }}\n >\n {reaction.count}\n </span>\n </span>\n <span\n style={{\n alignItems: \"center\",\n background: colors.wash,\n borderRadius: px(999),\n color: colors.strong,\n display: \"inline-flex\",\n height: px(35),\n padding: `0 ${px(12)}px`,\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(26)} height={px(26)} fill=\"currentColor\">\n <path clipRule=\"evenodd\" d={ADD_REACTION} fillRule=\"evenodd\" />\n </svg>\n </span>\n </span>\n ) : null}\n </div>\n </div>\n\n {frame >= workingAt ? (\n <>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(16), margin: `${px(20)}px 0 ${px(16)}px`}}>\n <span data-odori-chrome style={{color: colors.muted, fontSize: px(small), fontWeight: 700}}>1 reply</span>\n <span style={{background: colors.edge, flex: 1, height: px(1)}} />\n </div>\n\n <div\n style={{\n display: \"flex\",\n gap: px(gutterY),\n opacity: working ? 1 : replied,\n transform: `translateY(${(1 - (working ? 1 : replied)) * px(8)}px)`,\n }}\n >\n {avatar(reply, avatarSize)}\n <div style={{minWidth: 0}}>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10), lineHeight: LINE}}>\n <span style={{color: colors.strong, fontSize: px(body), fontWeight: 900}}>{reply.author}</span>\n <span\n data-odori-chrome\n style={{\n background: colors.badge,\n borderRadius: px(4),\n color: colors.muted,\n fontSize: px(Math.round(body * 0.68)),\n fontWeight: 700,\n padding: `${px(2)}px ${px(8)}px`,\n }}\n >\n APP\n </span>\n {frame >= replyAt ? <span data-odori-chrome style={{color: colors.muted, fontSize: px(small)}}>{reply.time}</span> : null}\n </div>\n <div style={{marginTop: px(4), minHeight: px(36)}}>\n {working ? (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(8), height: px(32)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: colors.muted,\n borderRadius: px(999),\n height: px(10),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(10),\n }}\n />\n ))}\n </span>\n ) : (\n <span style={{color: colors.ink, fontSize: px(body), lineHeight: LINE}}>{reply.body}</span>\n )}\n </div>\n </div>\n </div>\n </>\n ) : null}\n </div>\n\n <div\n style={{\n background: colors.page,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(12),\n flex: \"none\",\n margin: `${px(20)}px ${px(gutterX)}px ${px(gutterY * 2)}px`,\n overflow: \"hidden\",\n }}\n >\n {/* The formatting strip sits on its own tint, and carries the\n set Slack carries: bold, italic, underline, strike, then\n link and the two lists, then quote, code and code block. */}\n <div\n style={{\n alignItems: \"center\",\n background: colors.strip,\n display: \"flex\",\n gap: px(2),\n padding: px(6),\n }}\n >\n {([\"bold\", \"italic\", \"underline\", \"strike\"] as const).map((name) => tool(name))}\n {rule(\"r1\")}\n {([\"link\", \"ordered\", \"bulleted\"] as const).map((name) => tool(name))}\n {rule(\"r2\")}\n {([\"quote\", \"code\", \"codeblock\"] as const).map((name) => tool(name))}\n </div>\n\n <div\n style={{\n color: sent ? colors.muted : colors.ink,\n fontSize: px(body),\n padding: `${px(18)}px ${px(16)}px ${px(24)}px`,\n }}\n >\n {sent ? (\n `Message #${channel}`\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(22),\n marginLeft: px(2),\n transform: `translateY(${px(4)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(2), padding: `0 ${px(10)}px ${px(10)}px`}}>\n {tool(\"attach\", true)}\n {tool(\"format\")}\n {tool(\"emoji\")}\n {tool(\"mention\")}\n {glyph(\n <svg viewBox=\"0 0 20 20\" width={px(26)} height={px(26)} fill=\"currentColor\">\n <circle cx=\"4\" cy=\"10\" r=\"1.6\" />\n <circle cx=\"10\" cy=\"10\" r=\"1.6\" />\n <circle cx=\"16\" cy=\"10\" r=\"1.6\" />\n </svg>,\n \"more\",\n )}\n\n {/* Send is a plain arrow until there is something to send,\n and only then does it turn green. */}\n <span\n style={{\n alignItems: \"center\",\n background: sent ? \"transparent\" : SEND,\n borderRadius: px(6),\n display: \"inline-flex\",\n marginLeft: \"auto\",\n }}\n >\n <span style={{alignItems: \"center\", display: \"flex\", padding: `${px(8)}px ${px(12)}px`}}>\n <svg viewBox=\"0 0 20 20\" width={px(23)} height={px(23)} fill={sent ? colors.muted : \"#FFFFFF\"}>\n <path clipRule=\"evenodd\" d={COMPOSER_ICONS.send} fillRule=\"evenodd\" />\n </svg>\n </span>\n <span\n style={{\n background: sent ? colors.edge : \"rgba(255,255,255,0.35)\",\n height: px(24),\n width: px(1),\n }}\n />\n <span style={{alignItems: \"center\", display: \"flex\", padding: `${px(8)}px ${px(10)}px`}}>\n <svg viewBox=\"0 0 20 20\" width={px(17)} height={px(17)} fill=\"none\" stroke={sent ? colors.muted : \"#FFFFFF\"} strokeWidth={2}>\n <path d=\"M5 8l5 5 5-5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </span>\n </span>\n </div>\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
3405
3405
  "target": "videos/components/slack/slack.tsx"
3406
3406
  },
3407
3407
  {
@@ -3965,12 +3965,12 @@
3965
3965
  "files": [
3966
3966
  {
3967
3967
  "path": "components/teams/teams.tsx",
3968
- "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\n\nexport type TeamsProps = {\n /** The team the channel belongs to. */\n team?: string;\n /** Channels under the team. */\n channels?: string[];\n /** The channel on screen. */\n channel?: string;\n /** The post that starts the thread. */\n ask?: {author: string; body: string; time?: string};\n /** The app's reply. */\n agent?: {name: string; body: string; time?: string};\n /** The card under the reply, which is how apps answer in Teams. */\n card?: {title: string; body?: string; action?: string};\n thinkingFrames?: number;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\nconst PALETTE = {\n dark: {rail: \"#0A0A0A\", side: \"#0F0F0F\", page: \"#141414\", card: \"#1F1F1F\", edge: \"#2E2E2E\", ink: \"#FFFFFF\", faint: \"#ADADAD\"},\n light: {rail: \"#EBEBEB\", side: \"#F5F5F5\", page: \"#FFFFFF\", card: \"#FAFAFA\", edge: \"#E1E1E1\", ink: \"#242424\", faint: \"#616161\"},\n};\nconst ACCENT = \"#5B5FC7\";\n/** Teams sets its interface in the system face, Segoe UI where present. */\nconst FONT = '-apple-system, system-ui, \"Segoe UI\", \"Segoe UI Web\", Helvetica, Arial, sans-serif';\n\n/**\n * A Teams channel with an app answering a post.\n *\n * Teams is card shaped rather than line shaped: a reply of consequence\n * arrives as a bordered block with a title and an action, not as a sentence\n * in a stream. The app rail down the far left and the tab strip over the\n * channel are the two pieces of furniture that say Teams before a word is\n * read, which is why they are drawn even though nothing in the shot uses them.\n */\nexport const Teams = ({\n team = \"Engineering\",\n channels = [\"General\", \"Releases\", \"Incidents\", \"Design\"],\n channel = \"Releases\",\n ask = {author: \"John Doe\", body: \"Can we get the launch cut re-rendered at web quality?\", time: \"9:38 AM\"},\n agent = {name: \"Odori\", body: \"Re-rendered and posted. Half the size, same frames.\", time: \"9:41 AM\"},\n card = {title: \"launch.mp4 · 12.4 MB\", body: \"1920x1080 · 30fps · web quality\", action: \"Open in Studio\"},\n thinkingFrames = 30,\n theme = \"dark\",\n charactersPerSecond = 34,\n}: TeamsProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n /* The typing is the person's: they compose the post, and the app answers\n with a message that is simply there when it arrives. */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(ask.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 20;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(ask.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const landed = frame >= replyAt;\n const working = frame >= workingAt && frame < replyAt;\n const cardIn = interpolate(landed ? frame : 0, [0, 12], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{background: \"#000000\", padding: px(60)}}>\n <div\n style={{\n borderRadius: px(14),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The app rail. */}\n <div\n style={{\n alignItems: \"center\",\n background: colors.rail,\n display: \"flex\",\n flex: \"none\",\n flexDirection: \"column\",\n gap: px(26),\n padding: `${px(22)}px 0`,\n width: px(88),\n }}\n >\n {[\"Activity\", \"Chat\", \"Teams\", \"Calendar\"].map((label, index) => (\n <div key={label} style={{alignItems: \"center\", display: \"flex\", flexDirection: \"column\", gap: px(5)}}>\n <span\n style={{\n background: index === 2 ? ACCENT : \"transparent\",\n border: `${px(2)}px solid ${index === 2 ? ACCENT : colors.faint}`,\n borderRadius: px(7),\n height: px(26),\n width: px(26),\n }}\n />\n <span style={{color: index === 2 ? colors.ink : colors.faint, fontSize: px(13)}}>{label}</span>\n </div>\n ))}\n </div>\n\n {/* The teams and channels list. */}\n <div style={{background: colors.side, borderRight: `${px(1)}px solid ${colors.edge}`, flex: \"none\", width: px(300)}}>\n <div style={{color: colors.ink, fontSize: px(24), fontWeight: 600, padding: `${px(22)}px ${px(20)}px ${px(16)}px`}}>\n Teams\n </div>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12), padding: `${px(8)}px ${px(20)}px`}}>\n <span style={{background: \"#8A6ED1\", borderRadius: px(8), height: px(36), width: px(36)}} />\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{team}</span>\n </div>\n {channels.map((name) => (\n <div\n key={name}\n style={{\n borderLeft: `${px(3)}px solid ${name === channel ? ACCENT : \"transparent\"}`,\n color: name === channel ? colors.ink : colors.faint,\n fontSize: px(19),\n fontWeight: name === channel ? 600 : 400,\n padding: `${px(9)}px ${px(20)}px ${px(9)}px ${px(48)}px`,\n }}\n >\n {name}\n </div>\n ))}\n </div>\n\n {/* The channel. */}\n <div style={{background: colors.page, display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div style={{borderBottom: `${px(1)}px solid ${colors.edge}`, padding: `${px(18)}px ${px(26)}px 0`}}>\n <div style={{color: colors.ink, fontSize: px(25), fontWeight: 600}}>{channel}</div>\n <div style={{display: \"flex\", gap: px(24), marginTop: px(14)}}>\n {[\"Posts\", \"Files\", \"Notes\"].map((tab, index) => (\n <span\n key={tab}\n style={{\n borderBottom: `${px(2)}px solid ${index === 0 ? ACCENT : \"transparent\"}`,\n color: index === 0 ? colors.ink : colors.faint,\n fontSize: px(19),\n paddingBottom: px(10),\n }}\n >\n {tab}\n </span>\n ))}\n </div>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", justifyContent: \"flex-end\", padding: `${px(20)}px ${px(26)}px`}}>\n <div\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(8),\n opacity: asked,\n padding: `${px(18)}px ${px(20)}px`,\n transform: `translateY(${(1 - asked) * px(8)}px)`,\n }}\n >\n {/* The avatar sits beside the name, and everything the person said\n lines up with the name rather than starting under the avatar. */}\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12)}}>\n <span\n style={{\n alignItems: \"center\",\n background: \"#8A6ED1\",\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(19),\n height: px(42),\n justifyContent: \"center\",\n width: px(42),\n }}\n >\n {ask.author.slice(0, 1).toUpperCase()}\n </span>\n <span style={{color: colors.ink, fontSize: px(21), fontWeight: 600}}>{ask.author}</span>\n <span style={{color: colors.faint, fontSize: px(17)}}>{ask.time}</span>\n </div>\n <div\n style={{\n color: colors.ink,\n fontSize: px(21),\n lineHeight: 1.5,\n marginTop: px(10),\n paddingLeft: px(42 + 12),\n }}\n >\n {ask.body}\n </div>\n\n <div\n style={{\n borderLeft: `${px(3)}px solid ${ACCENT}`,\n marginTop: px(18),\n opacity: working ? 1 : replied,\n paddingLeft: px(16),\n }}\n >\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10)}}>\n <span style={{background: ACCENT, borderRadius: px(7), height: px(34), width: px(34)}} />\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{agent.name}</span>\n <span\n style={{\n background: ACCENT,\n borderRadius: px(4),\n color: \"#FFFFFF\",\n fontSize: px(13),\n fontWeight: 600,\n padding: `${px(1)}px ${px(7)}px`,\n }}\n >\n APP\n </span>\n {landed ? <span style={{color: colors.faint, fontSize: px(17)}}>{agent.time}</span> : null}\n </div>\n <div style={{marginTop: px(8), minHeight: px(34), paddingLeft: px(34 + 10)}}>\n {working ? (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(7), height: px(28)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: colors.faint,\n borderRadius: px(999),\n height: px(9),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(9),\n }}\n />\n ))}\n </span>\n ) : (\n <span style={{color: colors.ink, fontSize: px(21), lineHeight: 1.5}}>{agent.body}</span>\n )}\n </div>\n\n {card && landed ? (\n <div\n style={{\n background: colors.card,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(8),\n marginLeft: px(34 + 10),\n marginTop: px(14),\n opacity: cardIn,\n padding: `${px(16)}px ${px(18)}px`,\n transform: `translateY(${(1 - cardIn) * px(8)}px)`,\n }}\n >\n <div style={{color: colors.ink, fontSize: px(21), fontWeight: 600}}>{card.title}</div>\n {card.body ? (\n <div style={{color: colors.faint, fontSize: px(18), marginTop: px(5)}}>{card.body}</div>\n ) : null}\n {card.action ? (\n <div\n style={{\n border: `${px(1)}px solid ${ACCENT}`,\n borderRadius: px(6),\n color: ACCENT,\n display: \"inline-block\",\n fontSize: px(18),\n fontWeight: 600,\n marginTop: px(12),\n padding: `${px(8)}px ${px(16)}px`,\n }}\n >\n {card.action}\n </div>\n ) : null}\n </div>\n ) : null}\n </div>\n </div>\n\n <div\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(8),\n color: sent ? colors.faint : colors.ink,\n fontSize: px(19),\n marginTop: px(18),\n padding: `${px(14)}px ${px(18)}px`,\n }}\n >\n {sent ? (\n \"Reply\"\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(20),\n marginLeft: px(2),\n transform: `translateY(${px(3)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
3968
+ "content": "import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from \"odori\";\n\nexport type TeamsProps = {\n /** The team the channel belongs to. */\n team?: string;\n /** Channels under the team. */\n channels?: string[];\n /** The channel on screen. */\n channel?: string;\n /** The post that starts the thread. */\n ask?: {author: string; body: string; time?: string};\n /** The app's reply. */\n agent?: {name: string; body: string; time?: string};\n /** The card under the reply, which is how apps answer in Teams. */\n card?: {title: string; body?: string; action?: string};\n thinkingFrames?: number;\n theme?: \"dark\" | \"light\";\n charactersPerSecond?: number;\n};\n\n/** The four places Teams keeps in its rail, each with its own glyph. */\nconst RAIL = [\n {label: \"Activity\", d: \"M5.5 8.5a4.5 4.5 0 0 1 9 0c0 3 1.1 4.1 1.5 4.5H4c.4-.4 1.5-1.5 1.5-4.5M8 15a2 2 0 0 0 4 0\"},\n {label: \"Chat\", d: \"M3 5.5A1.5 1.5 0 0 1 4.5 4h11A1.5 1.5 0 0 1 17 5.5v7a1.5 1.5 0 0 1-1.5 1.5H8l-4 3v-3H4.5A1.5 1.5 0 0 1 3 12.5z\"},\n {label: \"Teams\", d: \"M7.5 8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5M2.5 16c0-2.6 2.2-4.2 5-4.2s5 1.6 5 4.2M13 4.4a2.5 2.5 0 0 1 0 5M14 12c2 .5 3.5 1.9 3.5 4\"},\n {label: \"Calendar\", d: \"M4 5.5h12v11H4zM4 9h12M7.5 3v3M12.5 3v3\"},\n];\n\nconst PALETTE = {\n dark: {rail: \"#0A0A0A\", side: \"#0F0F0F\", page: \"#141414\", card: \"#1F1F1F\", edge: \"#2E2E2E\", ink: \"#FFFFFF\", faint: \"#ADADAD\"},\n light: {rail: \"#EBEBEB\", side: \"#F5F5F5\", page: \"#FFFFFF\", card: \"#FAFAFA\", edge: \"#E1E1E1\", ink: \"#252423\", faint: \"#616161\"},\n};\nconst ACCENT = \"#5B5FC7\";\n/** Teams sets its interface in the system face, Segoe UI where present. */\nconst FONT = '-apple-system, system-ui, \"Segoe UI\", \"Segoe UI Web\", Helvetica, Arial, sans-serif';\n\n/**\n * A Teams channel with an app answering a post.\n *\n * Teams is card shaped rather than line shaped: a reply of consequence\n * arrives as a bordered block with a title and an action, not as a sentence\n * in a stream. The app rail down the far left and the tab strip over the\n * channel are the two pieces of furniture that say Teams before a word is\n * read, which is why they are drawn even though nothing in the shot uses them.\n */\nexport const Teams = ({\n team = \"Engineering\",\n channels = [\"General\", \"Releases\", \"Incidents\", \"Design\"],\n channel = \"Releases\",\n ask = {author: \"John Doe\", body: \"Can we get the launch cut re-rendered at web quality?\", time: \"9:38 AM\"},\n agent = {name: \"Odori\", body: \"Re-rendered and posted. Half the size, same frames.\", time: \"9:41 AM\"},\n card = {title: \"launch.mp4 · 12.4 MB\", body: \"1920x1080 · 30fps · web quality\", action: \"Open in Studio\"},\n thinkingFrames = 30,\n theme = \"light\",\n charactersPerSecond = 34,\n}: TeamsProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const brand = useBrand();\n const scale = useDesignScale();\n const colors = PALETTE[theme];\n const px = (value: number) => value * scale;\n\n /* The typing is the person's: they compose the post, and the app answers\n with a message that is simply there when it arrives. */\n const typeFrom = 14;\n const askAt = typeFrom + typingFrames(ask.body, {charactersPerSecond}) + 10;\n const workingAt = askAt + 20;\n const replyAt = workingAt + thinkingFrames;\n const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const typed = useTyping(ask.body, {from: typeFrom, charactersPerSecond, chunk: 2});\n const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});\n const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});\n const sent = frame >= askAt;\n const landed = frame >= replyAt;\n const working = frame >= workingAt && frame < replyAt;\n const cardIn = interpolate(landed ? frame : 0, [0, 12], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{background: theme === \"light\" ? \"#DCDCDC\" : \"#000000\", padding: px(60)}}>\n <div\n style={{\n borderRadius: px(14),\n display: \"flex\",\n fontFamily: FONT,\n height: \"100%\",\n opacity: enter,\n overflow: \"hidden\",\n transform: `translateY(${(1 - enter) * px(12)}px)`,\n width: \"100%\",\n }}\n >\n {/* The app rail. */}\n <div\n style={{\n alignItems: \"center\",\n background: colors.rail,\n display: \"flex\",\n flex: \"none\",\n flexDirection: \"column\",\n gap: px(26),\n padding: `${px(22)}px 0`,\n width: px(88),\n }}\n >\n {RAIL.map((place, index) => (\n <div key={place.label} style={{alignItems: \"center\", display: \"flex\", flexDirection: \"column\", gap: px(5)}}>\n <span\n style={{\n alignItems: \"center\",\n background: index === 2 ? ACCENT : \"transparent\",\n borderRadius: px(7),\n color: index === 2 ? \"#FFFFFF\" : colors.faint,\n display: \"flex\",\n height: px(30),\n justifyContent: \"center\",\n width: px(30),\n }}\n >\n <svg viewBox=\"0 0 20 20\" width={px(21)} height={px(21)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n <path d={place.d} />\n </svg>\n </span>\n <span style={{color: index === 2 ? colors.ink : colors.faint, fontSize: px(13)}}>{place.label}</span>\n </div>\n ))}\n </div>\n\n {/* The teams and channels list. */}\n <div style={{background: colors.side, borderRight: `${px(1)}px solid ${colors.edge}`, flex: \"none\", width: px(300)}}>\n <div style={{color: colors.ink, fontSize: px(24), fontWeight: 600, padding: `${px(22)}px ${px(20)}px ${px(16)}px`}}>\n Teams\n </div>\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12), padding: `${px(8)}px ${px(20)}px`}}>\n <span style={{background: \"#8A6ED1\", borderRadius: px(8), height: px(36), width: px(36)}} />\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{team}</span>\n </div>\n {channels.map((name) => (\n <div\n key={name}\n style={{\n borderLeft: `${px(3)}px solid ${name === channel ? ACCENT : \"transparent\"}`,\n color: name === channel ? colors.ink : colors.faint,\n fontSize: px(19),\n fontWeight: name === channel ? 600 : 400,\n padding: `${px(9)}px ${px(20)}px ${px(9)}px ${px(48)}px`,\n }}\n >\n {name}\n </div>\n ))}\n </div>\n\n {/* The channel. */}\n <div style={{background: colors.page, display: \"flex\", flex: 1, flexDirection: \"column\", minWidth: 0}}>\n <div style={{borderBottom: `${px(1)}px solid ${colors.edge}`, padding: `${px(18)}px ${px(26)}px 0`}}>\n <div style={{color: colors.ink, fontSize: px(25), fontWeight: 600}}>{channel}</div>\n <div style={{display: \"flex\", gap: px(24), marginTop: px(14)}}>\n {[\"Posts\", \"Files\", \"Notes\"].map((tab, index) => (\n <span\n key={tab}\n style={{\n borderBottom: `${px(2)}px solid ${index === 0 ? ACCENT : \"transparent\"}`,\n color: index === 0 ? colors.ink : colors.faint,\n fontSize: px(19),\n paddingBottom: px(10),\n }}\n >\n {tab}\n </span>\n ))}\n </div>\n </div>\n\n <div style={{display: \"flex\", flex: 1, flexDirection: \"column\", justifyContent: \"flex-end\", padding: `${px(20)}px ${px(26)}px`}}>\n <div\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(8),\n opacity: asked,\n padding: `${px(18)}px ${px(20)}px`,\n transform: `translateY(${(1 - asked) * px(8)}px)`,\n }}\n >\n {/* The avatar sits beside the name, and everything the person said\n lines up with the name rather than starting under the avatar. */}\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(12)}}>\n <span\n style={{\n alignItems: \"center\",\n background: \"#8A6ED1\",\n borderRadius: px(999),\n color: \"#FFFFFF\",\n display: \"flex\",\n fontSize: px(19),\n height: px(42),\n justifyContent: \"center\",\n width: px(42),\n }}\n >\n {ask.author.slice(0, 1).toUpperCase()}\n </span>\n <span style={{color: colors.ink, fontSize: px(21), fontWeight: 600}}>{ask.author}</span>\n <span style={{color: colors.faint, fontSize: px(17)}}>{ask.time}</span>\n </div>\n <div\n style={{\n color: colors.ink,\n fontSize: px(21),\n lineHeight: 1.43,\n marginTop: px(10),\n paddingLeft: px(42 + 12),\n }}\n >\n {ask.body}\n </div>\n\n <div\n style={{\n borderLeft: `${px(3)}px solid ${ACCENT}`,\n marginTop: px(18),\n opacity: working ? 1 : replied,\n paddingLeft: px(16),\n }}\n >\n <div style={{alignItems: \"center\", display: \"flex\", gap: px(10)}}>\n <span style={{background: ACCENT, borderRadius: px(7), height: px(34), width: px(34)}} />\n <span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{agent.name}</span>\n <span\n style={{\n background: ACCENT,\n borderRadius: px(4),\n color: \"#FFFFFF\",\n fontSize: px(13),\n fontWeight: 600,\n padding: `${px(1)}px ${px(7)}px`,\n }}\n >\n APP\n </span>\n {landed ? <span style={{color: colors.faint, fontSize: px(17)}}>{agent.time}</span> : null}\n </div>\n <div style={{marginTop: px(8), minHeight: px(34), paddingLeft: px(34 + 10)}}>\n {working ? (\n <span style={{alignItems: \"center\", display: \"inline-flex\", gap: px(7), height: px(28)}}>\n {[0, 1, 2].map((dot) => (\n <span\n key={dot}\n style={{\n background: colors.faint,\n borderRadius: px(999),\n height: px(9),\n opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),\n width: px(9),\n }}\n />\n ))}\n </span>\n ) : (\n <span style={{color: colors.ink, fontSize: px(21), lineHeight: 1.43}}>{agent.body}</span>\n )}\n </div>\n\n {card && landed ? (\n <div\n style={{\n background: colors.card,\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(8),\n marginLeft: px(34 + 10),\n marginTop: px(14),\n opacity: cardIn,\n padding: `${px(16)}px ${px(18)}px`,\n transform: `translateY(${(1 - cardIn) * px(8)}px)`,\n }}\n >\n <div style={{color: colors.ink, fontSize: px(21), fontWeight: 600}}>{card.title}</div>\n {card.body ? (\n <div style={{color: colors.faint, fontSize: px(18), marginTop: px(5)}}>{card.body}</div>\n ) : null}\n {card.action ? (\n <div\n style={{\n border: `${px(1)}px solid ${ACCENT}`,\n borderRadius: px(6),\n color: ACCENT,\n display: \"inline-block\",\n fontSize: px(18),\n fontWeight: 600,\n marginTop: px(12),\n padding: `${px(8)}px ${px(16)}px`,\n }}\n >\n {card.action}\n </div>\n ) : null}\n </div>\n ) : null}\n </div>\n </div>\n\n <div\n style={{\n border: `${px(1)}px solid ${colors.edge}`,\n borderRadius: px(8),\n color: sent ? colors.faint : colors.ink,\n fontSize: px(19),\n marginTop: px(18),\n padding: `${px(14)}px ${px(18)}px`,\n }}\n >\n {sent ? (\n \"Reply\"\n ) : (\n <>\n {typed.text}\n {typed.caret ? (\n <span\n style={{\n background: colors.ink,\n display: \"inline-block\",\n height: px(20),\n marginLeft: px(2),\n transform: `translateY(${px(3)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </>\n )}\n </div>\n </div>\n </div>\n </div>\n </Fill>\n );\n};\n",
3969
3969
  "target": "videos/components/teams/teams.tsx"
3970
3970
  },
3971
3971
  {
3972
3972
  "path": "components/teams/teams.preview.tsx",
3973
- "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Teams} from \"./teams\";\n\nexport default defineComponentPreview({\n title: \"Microsoft Teams\",\n category: \"Products\",\n description: \"A Teams channel where an app answers a post with a card.\",\n component: Teams,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n team: {type: \"text\", defaultValue: \"Engineering\", maxLength: 24},\n channel: {type: \"text\", defaultValue: \"Releases\", maxLength: 24},\n thinkingFrames: {type: \"number\", defaultValue: 30, min: 0, max: 90, step: 2},\n theme: {type: \"select\", defaultValue: \"dark\", options: [\"dark\", \"light\"]},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Light\", props: {theme: \"light\"}},\n {name: \"No card\", props: {card: undefined}},\n ],\n});\n",
3973
+ "content": "import {defineComponentPreview} from \"odori/preview\";\nimport {Teams} from \"./teams\";\n\nexport default defineComponentPreview({\n title: \"Microsoft Teams\",\n category: \"Products\",\n description: \"A Teams channel where an app answers a post with a card.\",\n component: Teams,\n canvas: {width: 1920, height: 1080, duration: \"9s\"},\n controls: {\n team: {type: \"text\", defaultValue: \"Engineering\", maxLength: 24},\n channel: {type: \"text\", defaultValue: \"Releases\", maxLength: 24},\n thinkingFrames: {type: \"number\", defaultValue: 30, min: 0, max: 90, step: 2},\n theme: {type: \"select\", defaultValue: \"light\", options: [\"light\", \"dark\"]},\n charactersPerSecond: {type: \"number\", defaultValue: 34, min: 10, max: 80, step: 1},\n },\n examples: [\n {name: \"Default\", props: {}},\n {name: \"Dark\", props: {theme: \"dark\"}},\n {name: \"No card\", props: {card: undefined}},\n ],\n});\n",
3974
3974
  "target": "videos/components/teams/teams.preview.tsx"
3975
3975
  }
3976
3976
  ],
@@ -4382,7 +4382,7 @@
4382
4382
  "files": [
4383
4383
  {
4384
4384
  "path": "components/v0/v0.tsx",
4385
- "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from \"odori\";\n\nexport type V0Props = {\n /** The prompt typed into the composer. */\n prompt: string;\n /** The line above the composer. */\n heading?: string;\n /** What sits in the field before the first character. */\n placeholder?: string;\n /** Starters under the composer. They clear once typing starts. */\n suggestions?: string[];\n /** The model chip inside the composer. */\n model?: string;\n charactersPerSecond?: number;\n};\n\nconst PAGE = \"#000000\";\nconst FIELD = \"#121212\";\nconst EDGE = \"#2E2E2E\";\nconst INK = \"#EDEDED\";\nconst FAINT = \"#8F8F8F\";\n/** v0 sets its interface in Geist. */\nconst FONT = '\"GeistSans\", \"Geist\", ui-sans-serif, system-ui, -apple-system, sans-serif';\n\n/**\n * A generative build tool taking a brief.\n *\n * The composer is a dark textarea rather than a single line, because what gets\n * typed here is a paragraph describing something to build, not a question. The\n * heading above it holds the frame while the text lands, and the send control\n * fills only once there is something to send, so the shot has one change in it\n * rather than three.\n */\nexport const V0 = ({\n prompt,\n heading = \"What can I help you ship?\",\n placeholder = \"Describe what you want to build…\",\n suggestions = [\"Clone a screen\", \"Landing page\", \"Dashboard\", \"Sign up form\"],\n model = \"v0-1.5-lg\",\n charactersPerSecond = 26,\n}: V0Props) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const typed = useTyping(prompt, {from: 20, charactersPerSecond, chunk: 2});\n const enter = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});\n const cleared = interpolate(typed.length > 0 ? frame : 0, [20, 30], [0, 1], {easing: Easing.standard});\n const armed = interpolate(typed.length > 0 ? frame : 0, [26, 34], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", background: PAGE, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n maxWidth: px(1120),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(16)}px)`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n color: INK,\n fontFamily: FONT,\n fontSize: px(48),\n fontWeight: 500,\n letterSpacing: \"-0.03em\",\n marginBottom: px(34),\n textAlign: \"center\",\n }}\n >\n {heading}\n </div>\n\n <div\n style={{\n background: FIELD,\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(12),\n padding: `${px(26)}px ${px(24)}px ${px(18)}px`,\n }}\n >\n <div\n style={{\n color: typed.length > 0 ? INK : FAINT,\n fontFamily: FONT,\n fontSize: px(28),\n lineHeight: 1.5,\n minHeight: px(120),\n whiteSpace: \"pre-wrap\",\n }}\n >\n {typed.length > 0 ? typed.text : placeholder}\n {typed.caret ? (\n <span\n style={{\n background: INK,\n display: \"inline-block\",\n height: px(28),\n marginLeft: px(3),\n transform: `translateY(${px(4)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", justifyContent: \"space-between\", marginTop: px(14)}}>\n <span\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(8),\n color: FAINT,\n fontFamily: brand.typography.mono,\n fontSize: px(19),\n padding: `${px(7)}px ${px(14)}px`,\n }}\n >\n {model}\n </span>\n <span\n style={{\n alignItems: \"center\",\n background: `color-mix(in srgb, #FFFFFF ${armed * 100}%, ${EDGE})`,\n borderRadius: px(10),\n display: \"inline-flex\",\n height: px(52),\n justifyContent: \"center\",\n width: px(52),\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(24)} height={px(24)}>\n <path\n d=\"M12 19V5M12 5l-6 6M12 5l6 6\"\n fill=\"none\"\n stroke={armed > 0.5 ? \"#000000\" : FAINT}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.4}\n />\n </svg>\n </span>\n </div>\n </div>\n\n {suggestions.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: px(12),\n justifyContent: \"center\",\n marginTop: px(26),\n opacity: 1 - cleared,\n transform: `translateY(${cleared * px(-10)}px)`,\n }}\n >\n {suggestions.map((item) => (\n <span\n key={item}\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(999),\n color: FAINT,\n fontFamily: FONT,\n fontSize: px(21),\n padding: `${px(11)}px ${px(20)}px`,\n }}\n >\n {item}\n </span>\n ))}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
4385
+ "content": "import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from \"odori\";\n\nexport type V0Props = {\n /** The prompt typed into the composer. */\n prompt: string;\n /** The line above the composer. */\n heading?: string;\n /** What sits in the field before the first character. */\n placeholder?: string;\n /** Starters under the composer. They clear once typing starts. */\n suggestions?: string[];\n /** The model chip inside the composer. */\n model?: string;\n charactersPerSecond?: number;\n};\n\nconst PAGE = \"#000000\";\nconst FIELD = \"#121212\";\nconst EDGE = \"#2E2E2E\";\nconst INK = \"#EDEDED\";\nconst FAINT = \"#8F8F8F\";\n/** v0 sets its interface in Geist. */\nconst FONT = '\"GeistSans\", \"Geist\", ui-sans-serif, system-ui, -apple-system, sans-serif';\n\n/**\n * A generative build tool taking a brief.\n *\n * The composer is a dark textarea rather than a single line, because what gets\n * typed here is a paragraph describing something to build, not a question. The\n * heading above it holds the frame while the text lands, and the send control\n * fills only once there is something to send, so the shot has one change in it\n * rather than three.\n */\nexport const V0 = ({\n prompt,\n heading = \"What can I help you ship?\",\n placeholder = \"Describe what you want to build…\",\n suggestions = [\"Clone a screen\", \"Landing page\", \"Dashboard\", \"Sign up form\"],\n model = \"v0-1.5-lg\",\n charactersPerSecond = 26,\n}: V0Props) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const px = (value: number) => value * scale;\n\n const typed = useTyping(prompt, {from: 20, charactersPerSecond, chunk: 2});\n const enter = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});\n const cleared = interpolate(typed.length > 0 ? frame : 0, [20, 30], [0, 1], {easing: Easing.standard});\n const armed = interpolate(typed.length > 0 ? frame : 0, [26, 34], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: \"center\", background: PAGE, justifyContent: \"center\", padding: px(120)}}>\n <div\n style={{\n maxWidth: px(1120),\n opacity: enter,\n transform: `translateY(${(1 - enter) * px(16)}px)`,\n width: \"100%\",\n }}\n >\n <div\n style={{\n color: INK,\n fontFamily: FONT,\n fontSize: px(48),\n fontWeight: 600,\n letterSpacing: \"-0.03em\",\n marginBottom: px(34),\n textAlign: \"center\",\n }}\n >\n {heading}\n </div>\n\n <div\n style={{\n background: FIELD,\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(12),\n padding: `${px(26)}px ${px(24)}px ${px(18)}px`,\n }}\n >\n <div\n style={{\n color: typed.length > 0 ? INK : FAINT,\n fontFamily: FONT,\n fontSize: px(28),\n lineHeight: 1.5,\n minHeight: px(120),\n whiteSpace: \"pre-wrap\",\n }}\n >\n {typed.length > 0 ? typed.text : placeholder}\n {typed.caret ? (\n <span\n style={{\n background: INK,\n display: \"inline-block\",\n height: px(28),\n marginLeft: px(3),\n transform: `translateY(${px(4)}px)`,\n width: px(2),\n }}\n />\n ) : null}\n </div>\n\n <div style={{alignItems: \"center\", display: \"flex\", justifyContent: \"space-between\", marginTop: px(14)}}>\n <span\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(8),\n color: FAINT,\n fontFamily: brand.typography.mono,\n fontSize: px(19),\n padding: `${px(7)}px ${px(14)}px`,\n }}\n >\n {model}\n </span>\n <span\n style={{\n alignItems: \"center\",\n background: `color-mix(in srgb, #FFFFFF ${armed * 100}%, ${EDGE})`,\n borderRadius: px(10),\n display: \"inline-flex\",\n height: px(52),\n justifyContent: \"center\",\n width: px(52),\n }}\n >\n <svg viewBox=\"0 0 24 24\" width={px(24)} height={px(24)}>\n <path\n d=\"M12 19V5M12 5l-6 6M12 5l6 6\"\n fill=\"none\"\n stroke={armed > 0.5 ? \"#000000\" : FAINT}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2.4}\n />\n </svg>\n </span>\n </div>\n </div>\n\n {suggestions.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: px(12),\n justifyContent: \"center\",\n marginTop: px(26),\n opacity: 1 - cleared,\n transform: `translateY(${cleared * px(-10)}px)`,\n }}\n >\n {suggestions.map((item) => (\n <span\n key={item}\n style={{\n border: `${px(1)}px solid ${EDGE}`,\n borderRadius: px(999),\n color: FAINT,\n fontFamily: FONT,\n fontSize: px(21),\n padding: `${px(11)}px ${px(20)}px`,\n }}\n >\n {item}\n </span>\n ))}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n",
4386
4386
  "target": "videos/components/v0/v0.tsx"
4387
4387
  },
4388
4388
  {