@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.
package/bin/odori.mjs CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import {existsSync} from "node:fs";
3
- import {createRequire} from "node:module";
4
3
  import {dirname, resolve} from "node:path";
5
4
  import {fileURLToPath, pathToFileURL} from "node:url";
6
5
 
@@ -19,21 +18,44 @@ import {fileURLToPath, pathToFileURL} from "node:url";
19
18
  const here = dirname(fileURLToPath(import.meta.url));
20
19
  const built = resolve(here, "..", "dist", "cli.js");
21
20
 
21
+ /*
22
+ * import.meta.resolve, not createRequire().resolve.
23
+ *
24
+ * The published runtime is ESM only: its exports declare `import` and no
25
+ * `require`, so a CommonJS resolver cannot see it at all and throws
26
+ * ERR_PACKAGE_PATH_NOT_EXPORTED. The catch below then answered "yes, source",
27
+ * which meant every installed copy took the transform branch. Not just the
28
+ * pause this was written to avoid, but a real failure: the CLI would run its
29
+ * TypeScript through tsx while Node was already stripping types itself, and
30
+ * loading a project's odori.config.ts landed in a require(esm) cycle that
31
+ * Node refuses outright. odori doctor could not run in a new project.
32
+ *
33
+ * import.meta.resolve honours the same conditions the CLI's own imports use,
34
+ * so it answers the question that was actually being asked.
35
+ */
22
36
  const runtimeIsSource = () => {
23
37
  try {
24
- return /\.tsx?$/.test(createRequire(import.meta.url).resolve("odori"));
38
+ return /\.tsx?$/.test(import.meta.resolve("odori"));
25
39
  } catch {
26
40
  // Unresolvable from here: let the transform handle whatever it is.
27
41
  return true;
28
42
  }
29
43
  };
30
44
 
31
- if (existsSync(built) && !runtimeIsSource()) {
32
- const {run} = await import(pathToFileURL(built).href);
33
- process.exitCode = await run(process.argv.slice(2));
34
- } else {
35
- const {register} = await import("tsx/esm/api");
36
- register();
37
- const {run} = await import("../src/cli.ts");
38
- process.exitCode = await run(process.argv.slice(2));
39
- }
45
+ /*
46
+ * The transform is registered either way, because it is not there for the
47
+ * CLI. A project's videos are .tsx, and Node's own type stripping erases
48
+ * types without understanding JSX, so `odori list` on a real project fails
49
+ * with "Unknown file extension .tsx" the moment nothing is registered.
50
+ *
51
+ * What the branch decides is only where the CLI's own code comes from: its
52
+ * build when there is one, its source inside this repository, where `odori`
53
+ * resolves to a .tsx the build could not have linked against.
54
+ */
55
+ const {register} = await import("tsx/esm/api");
56
+ register();
57
+
58
+ const {run} = await import(
59
+ existsSync(built) && !runtimeIsSource() ? pathToFileURL(built).href : "../src/cli.ts"
60
+ );
61
+ process.exitCode = await run(process.argv.slice(2));
@@ -317,7 +317,7 @@ var toComponent = (item) => ({
317
317
  });
318
318
  var snapshotItems = async () => {
319
319
  try {
320
- const loaded = await import("./registry-snapshot-JEVXYGS2.js");
320
+ const loaded = await import("./registry-snapshot-ADKSTGDR.js");
321
321
  return loaded.default.items;
322
322
  } catch {
323
323
  throw new Error(
@@ -2990,7 +2990,7 @@ var doctorCommand = async (root = process.cwd()) => {
2990
2990
  };
2991
2991
 
2992
2992
  // src/commands/init.ts
2993
- import { mkdir as mkdir15, writeFile as writeFile16 } from "fs/promises";
2993
+ import { mkdir as mkdir15, readFile as readFile15, writeFile as writeFile16 } from "fs/promises";
2994
2994
  import { existsSync as existsSync19 } from "fs";
2995
2995
  import { relative as relative10, resolve as resolve22 } from "path";
2996
2996
 
@@ -3125,8 +3125,28 @@ var initCommand = async (root = process.cwd()) => {
3125
3125
  await writeFile16(file, contents, "utf8");
3126
3126
  log.success(`Created ${relative10(root, file)}`);
3127
3127
  }
3128
+ await ensureModuleType(root);
3128
3129
  log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
3129
3130
  };
3131
+ var ensureModuleType = async (root) => {
3132
+ const file = resolve22(root, "package.json");
3133
+ if (!existsSync19(file)) {
3134
+ log.warn('No package.json here. Odori needs an ESM package: run npm init, then add "type": "module".');
3135
+ return;
3136
+ }
3137
+ let manifest;
3138
+ try {
3139
+ manifest = JSON.parse(await readFile15(file, "utf8"));
3140
+ } catch {
3141
+ log.warn('package.json is not readable JSON, so "type": "module" was not set. Odori needs it.');
3142
+ return;
3143
+ }
3144
+ if (manifest.type === "module") return;
3145
+ manifest.type = "module";
3146
+ await writeFile16(file, `${JSON.stringify(manifest, null, 2)}
3147
+ `, "utf8");
3148
+ log.success('Set "type": "module" in package.json');
3149
+ };
3130
3150
 
3131
3151
  // src/commands/inspect.ts
3132
3152
  import { isOdoriSchema, resolveEntryLayout as resolveEntryLayout5 } from "odori";
@@ -3339,7 +3359,7 @@ var checkInstalledContracts = async (config, videos) => {
3339
3359
  };
3340
3360
 
3341
3361
  // src/determinism.ts
3342
- import { readdir as readdir8, readFile as readFile15 } from "fs/promises";
3362
+ import { readdir as readdir8, readFile as readFile16 } from "fs/promises";
3343
3363
  import { existsSync as existsSync21 } from "fs";
3344
3364
  import { join as join7, relative as relative11, resolve as resolve25 } from "path";
3345
3365
  var FORBIDDEN = [
@@ -3386,7 +3406,7 @@ var checkDeterminism = async (config) => {
3386
3406
  if (!existsSync21(root)) return [];
3387
3407
  const files = await walk2(root);
3388
3408
  const findings = await Promise.all(
3389
- files.map(async (file) => scanSource(await readFile15(file, "utf8"), relative11(config.root, file)))
3409
+ files.map(async (file) => scanSource(await readFile16(file, "utf8"), relative11(config.root, file)))
3390
3410
  );
3391
3411
  return findings.flat();
3392
3412
  };
@@ -3462,6 +3482,18 @@ var FRAME_SCRIPT = `(() => {
3462
3482
  if (!media && text.length === 0) continue;
3463
3483
 
3464
3484
  var label = text.length > 0 ? '"' + text.slice(0, 32) + '"' : "<" + node.tagName.toLowerCase() + ">";
3485
+
3486
+ /*
3487
+ * Chrome is exempt from the readability floor.
3488
+ *
3489
+ * A component that recreates somebody else's app is right to draw that
3490
+ * app's sidebar at the size that app draws it. Slack's rail really is
3491
+ * 16px, and enlarging it until this check is happy produces a Slack that
3492
+ * does not look like Slack. That text is furniture: it says "this is
3493
+ * Slack", and nobody is meant to read it. What has to be legible is the
3494
+ * content the video is actually about, which is what stays checked.
3495
+ */
3496
+ var chrome = node.closest("[data-odori-chrome]") !== null;
3465
3497
  if (
3466
3498
  box.right > bounds.right + 1 ||
3467
3499
  box.left < bounds.left - 1 ||
@@ -3474,8 +3506,18 @@ var FRAME_SCRIPT = `(() => {
3474
3506
  // Normalize against the shorter side, the same reference useDesignScale
3475
3507
  // uses, so a vertical cut is not judged as if it were letterboxed.
3476
3508
  var reference = Math.min(bounds.width, bounds.height);
3477
- var relative = (parseFloat(style.fontSize) / reference) * 1080;
3478
- if (text.length > 0 && relative > 0 && relative < 20) {
3509
+ /*
3510
+ * What the glyphs actually measure on screen, not what the stylesheet
3511
+ * asked for. A scene that pushes in on a surface makes its text bigger,
3512
+ * and reading font-size alone called that a violation while the viewer
3513
+ * was looking at type half again as large. The ratio of the painted box
3514
+ * to the laid-out box is every ancestor transform multiplied together,
3515
+ * which is exactly the correction wanted, and it cancels the root's own
3516
+ * fit scale because the reference is measured through it too.
3517
+ */
3518
+ var zoom = node.offsetHeight > 0 ? box.height / node.offsetHeight : 1;
3519
+ var relative = ((parseFloat(style.fontSize) * zoom) / reference) * 1080;
3520
+ if (!chrome && text.length > 0 && relative > 0 && relative < 20) {
3479
3521
  var note = label + " at " + Math.round(relative) + "px";
3480
3522
  if (small.indexOf(note) < 0) small.push(note);
3481
3523
  }
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  checkFlags,
3
3
  parseArgs,
4
4
  run
5
- } from "./chunk-RHG23EWW.js";
5
+ } from "./chunk-6CE7U5KB.js";
6
6
  export {
7
7
  checkFlags,
8
8
  parseArgs,
package/dist/index.js CHANGED
@@ -75,7 +75,7 @@ import {
75
75
  withServer,
76
76
  writeGenerated,
77
77
  writePrepareCache
78
- } from "./chunk-RHG23EWW.js";
78
+ } from "./chunk-6CE7U5KB.js";
79
79
  export {
80
80
  CHROME_BUILD,
81
81
  FORMATS,
@@ -904,7 +904,7 @@ export default defineComponentPreview({
904
904
  files: [
905
905
  {
906
906
  path: "components/claude-code/claude-code.tsx",
907
- 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 = ["\u273B", "\u2733", "\u273D", "\u273B", "\u273A", "\u2738"];\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)}}>\u273B</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}\u2026</span>\n <span>\n ({elapsed}s \xB7 \u2191 {spent} tokens \xB7 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)}}>\u23FA</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)}}>\u23BF</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',
907
+ 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 = ["\u273B", "\u2733", "\u273D", "\u273B", "\u273A", "\u2738"];\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)}}>\u273B</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}\u2026</span>\n <span>\n ({elapsed}s \xB7 \u2191 {spent} tokens \xB7 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)}}>\u23FA</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)}}>\u23BF</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',
908
908
  target: "videos/components/claude-code/claude-code.tsx"
909
909
  },
910
910
  {
@@ -2521,7 +2521,7 @@ export default defineComponentPreview({
2521
2521
  files: [
2522
2522
  {
2523
2523
  path: "components/imessage/imessage.tsx",
2524
- 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',
2524
+ 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',
2525
2525
  target: "videos/components/imessage/imessage.tsx"
2526
2526
  },
2527
2527
  {
@@ -3633,7 +3633,7 @@ export default defineComponentPreview({
3633
3633
  files: [
3634
3634
  {
3635
3635
  path: "components/slack/slack.tsx",
3636
- 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: "\u{1F440}", 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 \u2014 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',
3636
+ 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: "\u{1F440}", 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 \u2014 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',
3637
3637
  target: "videos/components/slack/slack.tsx"
3638
3638
  },
3639
3639
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "description": "The odori command line: discovery, Studio, component installation, stills, tests, and export jobs.",
6
6
  "license": "Apache-2.0",
@@ -28,7 +28,7 @@
28
28
  "playwright-core": "1.55.0",
29
29
  "tsx": "4.20.5",
30
30
  "vite": "7.3.0",
31
- "odori": "0.0.5"
31
+ "odori": "0.0.6"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "22.19.0",
@@ -36,7 +36,7 @@
36
36
  "@types/react-dom": "19.2.3",
37
37
  "tsup": "^8.5.1",
38
38
  "typescript": "5.9.3",
39
- "@odori/registry": "0.0.5"
39
+ "@odori/registry": "0.0.6"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public"