@odori/cli 0.0.5 → 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.
@@ -1,4 +1,4 @@
1
- import {mkdir, writeFile} from "node:fs/promises";
1
+ import {mkdir, readFile, writeFile} from "node:fs/promises";
2
2
  import {existsSync} from "node:fs";
3
3
  import {relative, resolve} from "node:path";
4
4
  import {defaultConfig} from "../config";
@@ -52,5 +52,38 @@ export const initCommand = async (root = process.cwd()) => {
52
52
  log.success(`Created ${relative(root, file)}`);
53
53
  }
54
54
 
55
+ await ensureModuleType(root);
55
56
  log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
56
57
  };
58
+
59
+ /**
60
+ * A project scaffolded here has to be ESM, and say so.
61
+ *
62
+ * Videos are `.tsx` and the config is `.ts`. Without `"type": "module"` a
63
+ * package is CommonJS, the loader reaches those files through require, and
64
+ * loading the config lands in a require(esm) cycle Node refuses outright. The
65
+ * result was that `odori init` finished by telling you to run `odori doctor`,
66
+ * and `odori doctor` could not read the config it had just written. The
67
+ * scaffold from `create-odori` sets this; a project adopting Odori by hand
68
+ * never did.
69
+ */
70
+ const ensureModuleType = async (root: string) => {
71
+ const file = resolve(root, "package.json");
72
+ if (!existsSync(file)) {
73
+ log.warn("No package.json here. Odori needs an ESM package: run npm init, then add \"type\": \"module\".");
74
+ return;
75
+ }
76
+ let manifest: Record<string, unknown>;
77
+ try {
78
+ manifest = JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
79
+ } catch {
80
+ log.warn("package.json is not readable JSON, so \"type\": \"module\" was not set. Odori needs it.");
81
+ return;
82
+ }
83
+ if (manifest.type === "module") return;
84
+ // Written back with the key in place rather than appended, so the file still
85
+ // reads the way npm would have written it.
86
+ manifest.type = "module";
87
+ await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
88
+ log.success('Set "type": "module" in package.json');
89
+ };
@@ -97,6 +97,18 @@ const FRAME_SCRIPT = `(() => {
97
97
  if (!media && text.length === 0) continue;
98
98
 
99
99
  var label = text.length > 0 ? '"' + text.slice(0, 32) + '"' : "<" + node.tagName.toLowerCase() + ">";
100
+
101
+ /*
102
+ * Chrome is exempt from the readability floor.
103
+ *
104
+ * A component that recreates somebody else's app is right to draw that
105
+ * app's sidebar at the size that app draws it. Slack's rail really is
106
+ * 16px, and enlarging it until this check is happy produces a Slack that
107
+ * does not look like Slack. That text is furniture: it says "this is
108
+ * Slack", and nobody is meant to read it. What has to be legible is the
109
+ * content the video is actually about, which is what stays checked.
110
+ */
111
+ var chrome = node.closest("[data-odori-chrome]") !== null;
100
112
  if (
101
113
  box.right > bounds.right + 1 ||
102
114
  box.left < bounds.left - 1 ||
@@ -109,8 +121,18 @@ const FRAME_SCRIPT = `(() => {
109
121
  // Normalize against the shorter side, the same reference useDesignScale
110
122
  // uses, so a vertical cut is not judged as if it were letterboxed.
111
123
  var reference = Math.min(bounds.width, bounds.height);
112
- var relative = (parseFloat(style.fontSize) / reference) * 1080;
113
- if (text.length > 0 && relative > 0 && relative < 20) {
124
+ /*
125
+ * What the glyphs actually measure on screen, not what the stylesheet
126
+ * asked for. A scene that pushes in on a surface makes its text bigger,
127
+ * and reading font-size alone called that a violation while the viewer
128
+ * was looking at type half again as large. The ratio of the painted box
129
+ * to the laid-out box is every ancestor transform multiplied together,
130
+ * which is exactly the correction wanted, and it cancels the root's own
131
+ * fit scale because the reference is measured through it too.
132
+ */
133
+ var zoom = node.offsetHeight > 0 ? box.height / node.offsetHeight : 1;
134
+ var relative = ((parseFloat(style.fontSize) * zoom) / reference) * 1080;
135
+ if (!chrome && text.length > 0 && relative > 0 && relative < 20) {
114
136
  var note = label + " at " + Math.round(relative) + "px";
115
137
  if (small.indexOf(note) < 0) small.push(note);
116
138
  }
@@ -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
  {
@@ -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(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: \"68%\",\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 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
  {
@@ -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
  {
package/studio/index.html CHANGED
@@ -32,6 +32,32 @@
32
32
  root.style.colorScheme = theme;
33
33
  root.style.background = theme === "dark" ? "#000000" : "#ffffff";
34
34
  })();
35
+
36
+ // The panes, for the same reason and one step further: the grid columns
37
+ // are animated, so a pane whose state only arrives with React does not
38
+ // merely flash, it plays its opening or closing on every reload. The
39
+ // list defaults closed, the inspector open, matching the components; a
40
+ // stored width is restored here too, or the pane would slide from the
41
+ // default to it.
42
+ (function () {
43
+ var read = function (key) {
44
+ try {
45
+ return localStorage.getItem(key);
46
+ } catch (error) {
47
+ return null;
48
+ }
49
+ };
50
+ var root = document.documentElement;
51
+ root.dataset.navigator = read("odori-navigator-collapsed") === "false" ? "open" : "collapsed";
52
+ root.dataset.inspector = read("odori-inspector-collapsed") === "true" ? "collapsed" : "open";
53
+
54
+ var width = function (key, property) {
55
+ var stored = parseInt(read(key), 10);
56
+ if (stored > 0) root.style.setProperty(property, stored + "px");
57
+ };
58
+ width("odori-navigator-width", "--navigator-column");
59
+ width("odori-inspector-width", "--inspector-width");
60
+ })();
35
61
  </script>
36
62
  </head>
37
63
  <body>
@@ -1,11 +1,29 @@
1
- import {useEffect, useRef, useState, type ReactNode, type RefObject} from "react";
1
+ import {useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject} from "react";
2
+
3
+ /**
4
+ * Before the browser paints, and inert on the server.
5
+ *
6
+ * The fit has to be known for the first frame or it is not a fit, it is a
7
+ * correction the viewer watches happen.
8
+ */
9
+ const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
2
10
 
3
11
  /** Fit a fixed composition size into whatever space the container has. */
4
12
  export const useFitScale = (width: number, height: number): [RefObject<HTMLDivElement | null>, number] => {
5
13
  const container = useRef<HTMLDivElement>(null);
6
- const [scale, setScale] = useState(0.25);
14
+ /*
15
+ * Zero until measured, not a guess.
16
+ *
17
+ * This started at 0.25, so every load drew the composition at a quarter of
18
+ * its size and then snapped it to the real fit once an effect had run. That
19
+ * is the flash: a frame arriving small, and everything around it moving as
20
+ * it grew. Zero paints nothing, and the measurement below lands before the
21
+ * browser paints at all, so the first frame the viewer sees is the right
22
+ * one.
23
+ */
24
+ const [scale, setScale] = useState(0);
7
25
 
8
- useEffect(() => {
26
+ useBeforePaint(() => {
9
27
  const element = container.current;
10
28
  if (!element) return;
11
29
  const fit = () => {
@@ -41,6 +59,22 @@ export const CanvasStage = ({
41
59
  children: ReactNode;
42
60
  }) => {
43
61
  const [container, scale] = useFitScale(width, height);
62
+ /*
63
+ * Brand faces arrive after the composition mounts, and text set in a face
64
+ * that lands late repaints at a different width. The gallery cards already
65
+ * hold their pixels until the faces are in; the stage is the one place it
66
+ * matters most, because it is the biggest thing on screen.
67
+ */
68
+ const [fontsReady, setFontsReady] = useState(false);
69
+ useEffect(() => {
70
+ let live = true;
71
+ void document.fonts.ready.then(() => {
72
+ if (live) setFontsReady(true);
73
+ });
74
+ return () => {
75
+ live = false;
76
+ };
77
+ }, []);
44
78
 
45
79
  return (
46
80
  <div className="viewport">
@@ -54,6 +88,9 @@ export const CanvasStage = ({
54
88
  {
55
89
  height: Math.round(height * scale),
56
90
  width: Math.round(width * scale),
91
+ // The box is laid out; only its contents wait for the faces, so
92
+ // nothing around it moves when they arrive.
93
+ visibility: scale > 0 && fontsReady ? undefined : "hidden",
57
94
  "--safe-x": `${(safeArea?.x ?? 0) * scale}px`,
58
95
  "--safe-y": `${(safeArea?.y ?? 0) * scale}px`,
59
96
  } as React.CSSProperties