| 1 | /** |
| 2 | * The landing page's product tour: one story told as a timeline. Every |
| 3 | * frame is a pure function of the time into the loop, so the tour can be |
| 4 | * paused, resumed, jumped to a step, or shown still, and the server and |
| 5 | * the first client render agree (t = 0). |
| 6 | * |
| 7 | * The story: a person asks g1t in #web to fix the CSV export; g1t hands it |
| 8 | * to @otto and the review to @margo; Otto's desk works it, consulting |
| 9 | * Margo on the way; the pull request goes green and |
| 10 | * merges; g1t says it shipped and Izzy tells #support; Inky updates the |
| 11 | * docs page. |
| 12 | */ |
| 13 | |
| 14 | export type Scene = "chat" | "agents" | "code" | "docs"; |
| 15 | |
| 16 | /** Where the soft cursor points: an element marked `data-tour="<target>"`. */ |
| 17 | export type CursorTarget = |
| 18 | | "composer" |
| 19 | | "send" |
| 20 | | "rail-agents" |
| 21 | | "rail-code" |
| 22 | | "rail-chat" |
| 23 | | "rail-docs" |
| 24 | | "task-pull" |
| 25 | | "idle"; |
| 26 | |
| 27 | export type Frame = { |
| 28 | scene: Scene; |
| 29 | /** Characters of the person's message typed into the composer. */ |
| 30 | typed: number; |
| 31 | /** The person's message has been sent. */ |
| 32 | sent: boolean; |
| 33 | /** g1t shows as typing. */ |
| 34 | g1tTyping: boolean; |
| 35 | /** g1t's handoff reply, with the task card. */ |
| 36 | handoff: boolean; |
| 37 | /** Builder's steps done, 0 to STEPS.length. */ |
| 38 | steps: number; |
| 39 | /** Checks passed on the pull request, 0 to CHECKS.length. */ |
| 40 | checks: number; |
| 41 | /** The diff is shown on the pull request. */ |
| 42 | diff: boolean; |
| 43 | /** @reviewer approved. */ |
| 44 | approved: boolean; |
| 45 | /** The merge queue merged it. */ |
| 46 | merged: boolean; |
| 47 | /** The task card in #web says merged and deploying. */ |
| 48 | cardMerged: boolean; |
| 49 | /** g1t posted that it shipped. */ |
| 50 | shipped: boolean; |
| 51 | /** Izzy says she told #support. */ |
| 52 | izzy: boolean; |
| 53 | /** Otto consulted Margo, shown as a collapsed line on the task. */ |
| 54 | consult: boolean; |
| 55 | /** The docs page shows the changed line. */ |
| 56 | docUpdated: boolean; |
| 57 | cursor: CursorTarget; |
| 58 | /** The cursor is pressing. */ |
| 59 | click: boolean; |
| 60 | /** The cursor is shown at all. */ |
| 61 | cursorShown: boolean; |
| 62 | }; |
| 63 | |
| 64 | /** What the person types into #web. */ |
| 65 | export const ASK = "@g1t the CSV export times out for big accounts. Can we get it fixed before Thursday?"; |
| 66 | |
| 67 | export const STEPS: { text: string; cost: number }[] = [ |
| 68 | { text: "Read the export job and the two reports in #web", cost: 0.04 }, |
| 69 | { text: "Found it: every row loads before the first byte is sent", cost: 0.11 }, |
| 70 | { text: "Streamed rows in batches of 1,000", cost: 0.19 }, |
| 71 | { text: "Tested a 200,000-row account: 3.1 s, was 61 s", cost: 0.27 }, |
| 72 | { text: "Opened pull request #431", cost: 0.31 }, |
| 73 | ]; |
| 74 | |
| 75 | export const CHECKS = ["build", "test", "lint", "preview"]; |
| 76 | |
| 77 | /** When the person starts typing, and how long each character takes, in ms. */ |
| 78 | const TYPE_START = 1500; |
| 79 | |
| 80 | /** |
| 81 | * A human typing pace: about 45 ms a character, a little longer after a |
| 82 | * space, and a short hesitation after punctuation. Seeded, so every loop |
| 83 | * and every render types the same way. |
| 84 | */ |
| 85 | function typingTimes(text: string): number[] { |
| 86 | let seed = 7; |
| 87 | const random = () => { |
| 88 | seed = (seed * 16807) % 2147483647; |
| 89 | return (seed - 1) / 2147483646; |
| 90 | }; |
| 91 | const times: number[] = []; |
| 92 | let at = TYPE_START; |
| 93 | for (const char of text) { |
| 94 | at += 32 + random() * 34; |
| 95 | if (char === " ") at += random() * 30; |
| 96 | times.push(Math.round(at)); |
| 97 | if (char === "." || char === "?" || char === ",") at += 220 + random() * 160; |
| 98 | } |
| 99 | return times; |
| 100 | } |
| 101 | |
| 102 | const TYPED_AT = typingTimes(ASK); |
| 103 | const TYPED_END = TYPED_AT[TYPED_AT.length - 1]; |
| 104 | |
| 105 | /** The beats of the story, in ms from the start of the loop. */ |
| 106 | export const BEAT = { |
| 107 | composerClick: 1100, |
| 108 | typed: TYPED_END, |
| 109 | sendClick: TYPED_END + 700, |
| 110 | sent: TYPED_END + 800, |
| 111 | g1tTyping: TYPED_END + 1300, |
| 112 | handoff: TYPED_END + 2500, |
| 113 | railAgentsClick: TYPED_END + 4300, |
| 114 | agents: TYPED_END + 4450, |
| 115 | step0: TYPED_END + 5400, |
| 116 | stepGap: 1150, |
| 117 | pullClick: TYPED_END + 11900, |
| 118 | code: TYPED_END + 12050, |
| 119 | check0: TYPED_END + 12900, |
| 120 | checkGap: 650, |
| 121 | diff: TYPED_END + 13300, |
| 122 | approved: TYPED_END + 15900, |
| 123 | merged: TYPED_END + 17000, |
| 124 | railChatClick: TYPED_END + 18300, |
| 125 | chatAgain: TYPED_END + 18450, |
| 126 | cardMerged: TYPED_END + 19100, |
| 127 | shipped: TYPED_END + 20400, |
| 128 | izzy: TYPED_END + 21600, |
| 129 | railDocsClick: TYPED_END + 23000, |
| 130 | docs: TYPED_END + 23150, |
| 131 | docUpdated: TYPED_END + 24300, |
| 132 | end: TYPED_END + 29000, |
| 133 | } as const; |
| 134 | |
| 135 | /** The whole loop, in ms. */ |
| 136 | export const LOOP_MS = BEAT.end; |
| 137 | |
| 138 | /** The step pills under the frame: where each starts, for jumping to it. */ |
| 139 | export const PILLS: { scene: Scene; label: string; start: number; soon: boolean }[] = [ |
| 140 | { scene: "chat", label: "Chat", start: 0, soon: false }, |
| 141 | { scene: "agents", label: "Agents", start: BEAT.agents, soon: true }, |
| 142 | { scene: "code", label: "Code", start: BEAT.code, soon: false }, |
| 143 | { scene: "docs", label: "Docs", start: BEAT.docs, soon: true }, |
| 144 | ]; |
| 145 | |
| 146 | /** Which pill a time falls under. Back in chat after the merge is still Code's story. */ |
| 147 | export function pillAt(t: number): number { |
| 148 | let index = 0; |
| 149 | PILLS.forEach((pill, i) => { |
| 150 | if (t >= pill.start) index = i; |
| 151 | }); |
| 152 | return index; |
| 153 | } |
| 154 | |
| 155 | /** How far through its pill a time is, 0 to 1. */ |
| 156 | export function pillProgress(t: number): number { |
| 157 | const index = pillAt(t); |
| 158 | const start = PILLS[index].start; |
| 159 | const end = PILLS[index + 1]?.start ?? LOOP_MS; |
| 160 | return Math.min(1, Math.max(0, (t - start) / (end - start))); |
| 161 | } |
| 162 | |
| 163 | function count(t: number, first: number, gap: number, max: number): number { |
| 164 | if (t < first) return 0; |
| 165 | return Math.min(max, Math.floor((t - first) / gap) + 1); |
| 166 | } |
| 167 | |
| 168 | /** Where the cursor is headed, and whether it is pressing, at time t. */ |
| 169 | function cursorAt(t: number): { cursor: CursorTarget; click: boolean } { |
| 170 | const press = (at: number) => t >= at && t < at + 180; |
| 171 | const moves: [number, CursorTarget][] = [ |
| 172 | [300, "composer"], |
| 173 | [TYPED_END + 150, "send"], |
| 174 | [BEAT.handoff + 700, "rail-agents"], |
| 175 | [BEAT.step0 + 4 * BEAT.stepGap + 500, "task-pull"], |
| 176 | [BEAT.merged + 300, "rail-chat"], |
| 177 | [BEAT.izzy + 500, "rail-docs"], |
| 178 | [BEAT.docs + 500, "idle"], |
| 179 | ]; |
| 180 | let cursor: CursorTarget = "idle"; |
| 181 | for (const [at, target] of moves) if (t >= at) cursor = target; |
| 182 | const click = |
| 183 | press(BEAT.composerClick) || |
| 184 | press(BEAT.sendClick) || |
| 185 | press(BEAT.railAgentsClick) || |
| 186 | press(BEAT.pullClick) || |
| 187 | press(BEAT.railChatClick) || |
| 188 | press(BEAT.railDocsClick); |
| 189 | return { cursor, click }; |
| 190 | } |
| 191 | |
| 192 | /** The frame at time t (ms into the loop). */ |
| 193 | export function frameAt(time: number): Frame { |
| 194 | const t = ((time % LOOP_MS) + LOOP_MS) % LOOP_MS; |
| 195 | const scene: Scene = |
| 196 | t >= BEAT.docs ? "docs" : t >= BEAT.chatAgain ? "chat" : t >= BEAT.code ? "code" : t >= BEAT.agents ? "agents" : "chat"; |
| 197 | let typed = 0; |
| 198 | while (typed < TYPED_AT.length && TYPED_AT[typed] <= t) typed++; |
| 199 | const sent = t >= BEAT.sent; |
| 200 | const { cursor, click } = cursorAt(t); |
| 201 | return { |
| 202 | scene, |
| 203 | typed: sent ? 0 : typed, |
| 204 | sent, |
| 205 | g1tTyping: t >= BEAT.g1tTyping && t < BEAT.handoff, |
| 206 | handoff: t >= BEAT.handoff, |
| 207 | steps: count(t, BEAT.step0, BEAT.stepGap, STEPS.length), |
| 208 | checks: count(t, BEAT.check0, BEAT.checkGap, CHECKS.length), |
| 209 | diff: t >= BEAT.diff, |
| 210 | approved: t >= BEAT.approved, |
| 211 | merged: t >= BEAT.merged, |
| 212 | cardMerged: t >= BEAT.cardMerged, |
| 213 | shipped: t >= BEAT.shipped, |
| 214 | izzy: t >= BEAT.izzy, |
| 215 | consult: t >= BEAT.step0 + 2 * BEAT.stepGap + 500, |
| 216 | docUpdated: t >= BEAT.docUpdated, |
| 217 | cursor, |
| 218 | click, |
| 219 | cursorShown: t >= 200 && t < BEAT.end - 600, |
| 220 | }; |
| 221 | } |
| 222 | |
| 223 | /** A still frame for each pill, for reduced motion: the moment the step makes its point. */ |
| 224 | export const STILLS: Record<Scene, number> = { |
| 225 | chat: BEAT.handoff + 400, |
| 226 | agents: BEAT.step0 + 4 * BEAT.stepGap + 200, |
| 227 | code: BEAT.approved + 200, |
| 228 | docs: BEAT.docUpdated + 400, |
| 229 | }; |
| 230 | |
| 231 | /** Whether two frames would draw the same. */ |
| 232 | export function sameFrame(a: Frame, b: Frame): boolean { |
| 233 | for (const key of Object.keys(a) as (keyof Frame)[]) if (a[key] !== b[key]) return false; |
| 234 | return true; |
| 235 | } |