| 1 | import { useEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | import { tint } from "../marketing/diagrams/_colors"; |
| 3 | |
| 4 | const ACCENT = "var(--alc-accent-deep)"; |
| 5 | const ACCENT_TINT = tint("#B88A4A", 0.18); |
| 6 | |
| 7 | const tok = |
| 8 | (color: string) => |
| 9 | ({ children }: { children: ReactNode }) => ( |
| 10 | <span style={{ color }}>{children}</span> |
| 11 | ); |
| 12 | const K = tok("var(--alc-code-keyword)"); |
| 13 | const S = tok("var(--alc-code-string)"); |
| 14 | const F = tok("var(--alc-code-fn)"); |
| 15 | const V = tok("var(--alc-code-var)"); |
| 16 | const T = tok("var(--alc-code-type)"); |
| 17 | const C = ({ children }: { children: ReactNode }) => ( |
| 18 | <span style={{ color: "var(--alc-code-comment)", fontStyle: "italic" }}> |
| 19 | {children} |
| 20 | </span> |
| 21 | ); |
| 22 | |
| 23 | interface Step { |
| 24 | annotation: ReactNode; |
| 25 | show: { distribution: boolean; map: boolean; interpolate: boolean }; |
| 26 | } |
| 27 | |
| 28 | const STEPS: Step[] = [ |
| 29 | { |
| 30 | annotation: ( |
| 31 | <> |
| 32 | A resource produces typed <strong>Output</strong> attributes — lazy |
| 33 | references that resolve after deploy. |
| 34 | </> |
| 35 | ), |
| 36 | show: { distribution: false, map: false, interpolate: false }, |
| 37 | }, |
| 38 | { |
| 39 | annotation: ( |
| 40 | <> |
| 41 | Pass an Output as input to another resource. Alchemy draws the |
| 42 | dependency edge and deploys <strong>Photos</strong> first. |
| 43 | </> |
| 44 | ), |
| 45 | show: { distribution: true, map: false, interpolate: false }, |
| 46 | }, |
| 47 | { |
| 48 | annotation: ( |
| 49 | <> |
| 50 | <code>Output.map</code> composes lazily — the function runs only when |
| 51 | the upstream Output resolves. |
| 52 | </> |
| 53 | ), |
| 54 | show: { distribution: true, map: true, interpolate: false }, |
| 55 | }, |
| 56 | { |
| 57 | annotation: ( |
| 58 | <> |
| 59 | <code>Output.interpolate</code> splices Outputs into template strings — |
| 60 | same lazy graph, ergonomic syntax. |
| 61 | </> |
| 62 | ), |
| 63 | show: { distribution: true, map: true, interpolate: true }, |
| 64 | }, |
| 65 | ]; |
| 66 | |
| 67 | const STEP_DWELL = [2400, 2800, 2800, 3200]; |
| 68 | |
| 69 | export default function OutputGraphAnim() { |
| 70 | const wrapRef = useRef<HTMLDivElement>(null); |
| 71 | const [step, setStep] = useState(0); |
| 72 | |
| 73 | useEffect(() => { |
| 74 | let timer: ReturnType<typeof setTimeout> | null = null; |
| 75 | let cancelled = false; |
| 76 | const reduced = window.matchMedia( |
| 77 | "(prefers-reduced-motion: reduce)", |
| 78 | ).matches; |
| 79 | |
| 80 | const tick = (i: number) => { |
| 81 | if (cancelled) return; |
| 82 | setStep(i); |
| 83 | if (reduced) { |
| 84 | setStep(STEPS.length - 1); |
| 85 | return; |
| 86 | } |
| 87 | timer = setTimeout(() => tick((i + 1) % STEPS.length), STEP_DWELL[i]); |
| 88 | }; |
| 89 | |
| 90 | const obs = new IntersectionObserver( |
| 91 | (entries) => { |
| 92 | for (const e of entries) { |
| 93 | if (e.isIntersecting) { |
| 94 | tick(0); |
| 95 | obs.disconnect(); |
| 96 | } |
| 97 | } |
| 98 | }, |
| 99 | { threshold: 0.25 }, |
| 100 | ); |
| 101 | if (wrapRef.current) obs.observe(wrapRef.current); |
| 102 | return () => { |
| 103 | cancelled = true; |
| 104 | if (timer) clearTimeout(timer); |
| 105 | obs.disconnect(); |
| 106 | }; |
| 107 | }, []); |
| 108 | |
| 109 | const s = STEPS[step]!; |
| 110 | |
| 111 | // Highlight wrapper for code spans active in the current step. |
| 112 | const Hl = ({ |
| 113 | active, |
| 114 | children, |
| 115 | }: { |
| 116 | active: boolean; |
| 117 | children: ReactNode; |
| 118 | }) => ( |
| 119 | <span |
| 120 | style={{ |
| 121 | background: active ? ACCENT_TINT : "transparent", |
| 122 | boxShadow: active ? `0 0 0 1px ${tint("#B88A4A", 0.4)}` : "none", |
| 123 | borderRadius: 4, |
| 124 | padding: "1px 4px", |
| 125 | margin: "0 -4px", |
| 126 | transition: "background 320ms ease, box-shadow 320ms ease", |
| 127 | }} |
| 128 | > |
| 129 | {children} |
| 130 | </span> |
| 131 | ); |
| 132 | |
| 133 | return ( |
| 134 | <div ref={wrapRef} className="output-anim-grid" data-nosnippet=""> |
| 135 | {/* LEFT — code panel with progressive highlight */} |
| 136 | <div className="alc-code-block"> |
| 137 | <div className="alc-code-block__header"> |
| 138 | <span |
| 139 | className="alc-code-block__dot" |
| 140 | style={{ background: "var(--alc-danger)" }} |
| 141 | /> |
| 142 | <span |
| 143 | className="alc-code-block__dot" |
| 144 | style={{ background: "var(--alc-warn)" }} |
| 145 | /> |
| 146 | <span |
| 147 | className="alc-code-block__dot" |
| 148 | style={{ background: "var(--alc-accent-bright)" }} |
| 149 | /> |
| 150 | <span className="alc-code-block__filename">alchemy.run.ts</span> |
| 151 | </div> |
| 152 | <pre className="alc-code-block__pre"> |
| 153 | <Hl active={step === 0}> |
| 154 | <K>const</K> bucket = <K>yield</K>* <V>Cloudflare</V>.<F>Bucket</F>( |
| 155 | <S>"Photos"</S>); |
| 156 | {"\n"} |
| 157 | bucket.<V>bucketName</V>; <C>{"// Output<string>"}</C> |
| 158 | </Hl> |
| 159 | {"\n\n"} |
| 160 | <Hl active={step === 1}> |
| 161 | <K>const</K> dist = <K>yield</K>* <V>CloudFront</V>. |
| 162 | <F>Distribution</F>(<S>"CDN"</S>, {"{"} |
| 163 | {"\n "} |
| 164 | <V>origin</V>: bucket.<V>bucketName</V>,{"\n"} |
| 165 | {"});"} |
| 166 | </Hl> |
| 167 | {"\n\n"} |
| 168 | <Hl active={step === 2}> |
| 169 | <K>const</K> homepage = dist.<V>domainName</V>.<F>pipe</F>({"\n "} |
| 170 | <V>Output</V>.<F>map</F>((d) {"=>"} <S>{"`https://${d}`"}</S>), |
| 171 | {"\n"} |
| 172 | {");"} |
| 173 | </Hl> |
| 174 | {"\n\n"} |
| 175 | <Hl active={step === 3}> |
| 176 | <K>const</K> banner = <V>Output</V>.<F>interpolate</F> |
| 177 | <S>{"`served from ${dist.domainName}`"}</S>; |
| 178 | </Hl> |
| 179 | </pre> |
| 180 | </div> |
| 181 | |
| 182 | {/* RIGHT — progressive diagram + annotation */} |
| 183 | <div className="output-anim-stage"> |
| 184 | <div className="output-anim-graph" aria-hidden> |
| 185 | {/* Row 1 */} |
| 186 | <div className="output-anim-node output-anim-node--accent"> |
| 187 | <div className="output-anim-node__label">Bucket</div> |
| 188 | <div className="output-anim-node__id">"Photos"</div> |
| 189 | </div> |
| 190 | |
| 191 | <Edge dir="h" visible={s.show.distribution} label="bucketName" /> |
| 192 | |
| 193 | <div |
| 194 | className="output-anim-node output-anim-node--accent" |
| 195 | style={{ |
| 196 | opacity: s.show.distribution ? 1 : 0.15, |
| 197 | transform: s.show.distribution ? "scale(1)" : "scale(0.92)", |
| 198 | transition: "opacity 380ms ease, transform 380ms ease", |
| 199 | }} |
| 200 | > |
| 201 | <div className="output-anim-node__label">Distribution</div> |
| 202 | <div className="output-anim-node__id">"CDN"</div> |
| 203 | </div> |
| 204 | |
| 205 | {/* Row 2 (vertical edge under Distribution) */} |
| 206 | <div /> |
| 207 | <div /> |
| 208 | <Edge dir="v" visible={s.show.map} label="domainName" /> |
| 209 | |
| 210 | {/* Row 3 (Output.map under Distribution) */} |
| 211 | <div /> |
| 212 | <div /> |
| 213 | <div |
| 214 | className="output-anim-node output-anim-node--ghost" |
| 215 | style={{ |
| 216 | opacity: s.show.map ? 1 : 0.15, |
| 217 | transform: s.show.map ? "scale(1)" : "scale(0.92)", |
| 218 | transition: "opacity 380ms ease, transform 380ms ease", |
| 219 | }} |
| 220 | > |
| 221 | <div className="output-anim-node__label">Output.map</div> |
| 222 | <div className="output-anim-node__id">homepage</div> |
| 223 | </div> |
| 224 | </div> |
| 225 | |
| 226 | <div className="output-anim-steps" aria-hidden> |
| 227 | {STEPS.map((_, i) => ( |
| 228 | <span |
| 229 | key={i} |
| 230 | className={`output-anim-steps__dot${i === step ? " is-active" : ""}`} |
| 231 | /> |
| 232 | ))} |
| 233 | </div> |
| 234 | |
| 235 | <div className="output-anim-annotation">{s.annotation}</div> |
| 236 | </div> |
| 237 | </div> |
| 238 | ); |
| 239 | } |
| 240 | |
| 241 | function Edge({ |
| 242 | dir, |
| 243 | visible, |
| 244 | label, |
| 245 | }: { |
| 246 | dir: "h" | "v"; |
| 247 | visible: boolean; |
| 248 | label: string; |
| 249 | }) { |
| 250 | return ( |
| 251 | <div |
| 252 | className={`output-anim-edge output-anim-edge--${dir}${visible ? " is-on" : ""}`} |
| 253 | > |
| 254 | <span className="output-anim-edge__label">{label}</span> |
| 255 | </div> |
| 256 | ); |
| 257 | } |