1import { useEffect, useRef, useState, type ReactNode } from "react";
2import { tint } from "../marketing/diagrams/_colors";
3
4const ACCENT = "var(--alc-accent-deep)";
5const ACCENT_TINT = tint("#B88A4A", 0.18);
6
7const tok =
8 (color: string) =>
9 ({ children }: { children: ReactNode }) => (
10 <span style={{ color }}>{children}</span>
11 );
12const K = tok("var(--alc-code-keyword)");
13const S = tok("var(--alc-code-string)");
14const F = tok("var(--alc-code-fn)");
15const V = tok("var(--alc-code-var)");
16const T = tok("var(--alc-code-type)");
17const C = ({ children }: { children: ReactNode }) => (
18 <span style={{ color: "var(--alc-code-comment)", fontStyle: "italic" }}>
19 {children}
20 </span>
21);
22
23interface Step {
24 annotation: ReactNode;
25 show: { distribution: boolean; map: boolean; interpolate: boolean };
26}
27
28const 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
67const STEP_DWELL = [2400, 2800, 2800, 3200];
68
69export 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
241function 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}