1import { useEffect, useRef, useState, type ReactNode } from "react";
2import { AWS_COLOR, CF_COLOR, tint } from "../marketing/diagrams/_colors";
3
4const tok =
5 (color: string) =>
6 ({ children }: { children: ReactNode }) => (
7 <span style={{ color }}>{children}</span>
8 );
9const K = tok("var(--alc-code-keyword)");
10const S = tok("var(--alc-code-string)");
11const F = tok("var(--alc-code-fn)");
12const V = tok("var(--alc-code-var)");
13const T = tok("var(--alc-code-type)");
14const C = ({ children }: { children: ReactNode }) => (
15 <span style={{ color: "var(--alc-code-comment)", fontStyle: "italic" }}>
16 {children}
17 </span>
18);
19
20type ResKind = "kv" | "ddb" | "d1";
21
22interface Impl {
23 layer: string; // Layer name shown in code
24 resourceLabel: string; // node label
25 resourceSub: string; // node sub-label
26 bindCall: string; // bind call shown on the arrow
27 color: string;
28 kind: ResKind;
29}
30
31const D1_COLOR = "#0080FF";
32
33const IMPLS: Impl[] = [
34 {
35 layer: "SessionsKV",
36 resourceLabel: "Sessions",
37 resourceSub: "Cloudflare.KV.Namespace",
38 bindCall: "KV.ReadWriteNamespace",
39 color: CF_COLOR,
40 kind: "kv",
41 },
42 {
43 layer: "SessionsDynamoDB",
44 resourceLabel: "Sessions",
45 resourceSub: "AWS.DynamoDB.Table",
46 bindCall: "DynamoDB.PutItem",
47 color: AWS_COLOR,
48 kind: "ddb",
49 },
50 {
51 layer: "SessionsD1",
52 resourceLabel: "Sessions",
53 resourceSub: "Cloudflare.D1.Database",
54 bindCall: "D1Database.bind",
55 color: D1_COLOR,
56 kind: "d1",
57 },
58];
59
60const DWELL_MS = 2800;
61
62function ResourceIcon({ kind, color }: { kind: ResKind; color: string }) {
63 const fill = tint(color, 0.18);
64 if (kind === "kv") {
65 return (
66 <svg width="22" height="22" viewBox="0 0 32 32" fill="none" aria-hidden>
67 <circle
68 cx="11"
69 cy="16"
70 r="6"
71 fill={fill}
72 stroke={color}
73 strokeWidth="1.5"
74 />
75 <circle cx="11" cy="16" r="2.2" fill={color} />
76 <path
77 d="M17 16 L27 16 M23 16 L23 21 M27 16 L27 12"
78 stroke={color}
79 strokeWidth="1.5"
80 strokeLinecap="round"
81 />
82 </svg>
83 );
84 }
85 if (kind === "ddb") {
86 return (
87 <svg width="22" height="22" viewBox="0 0 32 32" fill="none" aria-hidden>
88 <ellipse
89 cx="16"
90 cy="8"
91 rx="10"
92 ry="3"
93 fill={fill}
94 stroke={color}
95 strokeWidth="1.4"
96 />
97 <ellipse
98 cx="16"
99 cy="16"
100 rx="10"
101 ry="3"
102 fill={fill}
103 stroke={color}
104 strokeWidth="1.4"
105 />
106 <ellipse
107 cx="16"
108 cy="24"
109 rx="10"
110 ry="3"
111 fill={fill}
112 stroke={color}
113 strokeWidth="1.4"
114 />
115 </svg>
116 );
117 }
118 // d1
119 return (
120 <svg width="22" height="22" viewBox="0 0 32 32" fill="none" aria-hidden>
121 <ellipse
122 cx="16"
123 cy="8"
124 rx="10"
125 ry="3"
126 fill={fill}
127 stroke={color}
128 strokeWidth="1.5"
129 />
130 <path
131 d="M6 8 V24 C6 25.7 10.5 27 16 27 C21.5 27 26 25.7 26 24 V8"
132 fill={fill}
133 stroke={color}
134 strokeWidth="1.5"
135 />
136 <path
137 d="M9 14 L23 14 M9 18 L23 18 M9 22 L19 22"
138 stroke={color}
139 strokeWidth="1.2"
140 strokeLinecap="round"
141 />
142 </svg>
143 );
144}
145
146export default function ServiceLayerSwap() {
147 const wrapRef = useRef<HTMLDivElement>(null);
148 const [idx, setIdx] = useState(0);
149
150 useEffect(() => {
151 let timer: ReturnType<typeof setTimeout> | null = null;
152 let cancelled = false;
153 const reduced = window.matchMedia(
154 "(prefers-reduced-motion: reduce)",
155 ).matches;
156
157 const tick = (i: number) => {
158 if (cancelled) return;
159 setIdx(i);
160 if (reduced) return;
161 timer = setTimeout(() => tick((i + 1) % IMPLS.length), DWELL_MS);
162 };
163 const obs = new IntersectionObserver(
164 (entries) => {
165 for (const e of entries) {
166 if (e.isIntersecting) {
167 tick(0);
168 obs.disconnect();
169 }
170 }
171 },
172 { threshold: 0.25 },
173 );
174 if (wrapRef.current) obs.observe(wrapRef.current);
175 return () => {
176 cancelled = true;
177 if (timer) clearTimeout(timer);
178 obs.disconnect();
179 };
180 }, []);
181
182 const active = IMPLS[idx]!;
183
184 return (
185 <div ref={wrapRef} className="service-swap-grid" data-nosnippet="">
186 {/* LEFT — code: Worker that consumes Sessions, Layer name swaps */}
187 <div className="alc-code-block">
188 <div className="alc-code-block__header">
189 <span
190 className="alc-code-block__dot"
191 style={{ background: "var(--alc-danger)" }}
192 />
193 <span
194 className="alc-code-block__dot"
195 style={{ background: "var(--alc-warn)" }}
196 />
197 <span
198 className="alc-code-block__dot"
199 style={{ background: "var(--alc-accent-bright)" }}
200 />
201 <span className="alc-code-block__filename">src/Api.ts</span>
202 </div>
203 <pre className="alc-code-block__pre">
204 <C>{"// Service interface — defined once."}</C>
205 {"\n"}
206 <K>export class</K> <T>Sessions</T> <K>extends</K> <V>Context</V>.
207 <F>Service</F>
208 {"<"}
209 <T>Sessions</T>, {"{"}
210 {"\n "}
211 <V>get</V>: (<V>id</V>: <T>string</T>) {"=>"} <V>Effect</V>.
212 <T>Effect</T>
213 {"<"}
214 <T>Session</T>
215 {">"};{"\n "}
216 <V>put</V>: (<V>s</V>: <T>Session</T>) {"=>"} <V>Effect</V>.
217 <T>Effect</T>
218 {"<"}
219 <T>void</T>
220 {">"};{"\n"}
221 {"}>()("}
222 <S>"Sessions"</S>
223 {") {}"}
224 {"\n\n"}
225 <C>{"// Worker code never knows which Layer is providing it."}</C>
226 {"\n"}
227 <K>export default class</K> <T>Api</T> <K>extends</K>{" "}
228 <V>Cloudflare</V>.<F>Worker</F>
229 {"<"}
230 <T>Api</T>
231 {">()("}
232 {"\n "}
233 <S>"Api"</S>,{"\n "}
234 <V>Effect</V>.<F>gen</F>(<K>function</K>* () {"{"}
235 {"\n "}
236 <K>const</K> sessions = <K>yield</K>* <V>Sessions</V>;{"\n "}
237 <K>return</K> {"{ fetch: handler(sessions) };"}
238 {"\n "}
239 {"}).pipe("}
240 {"\n "}
241 <C>
242 {"// Swap one Layer — resources, bindings, IAM all swap with it."}
243 </C>
244 {"\n "}
245 <V>Effect</V>.<F>provide</F>(
246 <span
247 key={idx}
248 className="layer-swap"
249 style={{ color: active.color, fontWeight: 600 }}
250 >
251 {active.layer}
252 </span>
253 ),
254 {"\n "}
255 {"),"}
256 {"\n"}
257 {") {}"}
258 </pre>
259 </div>
260
261 {/* RIGHT — visual: Sessions → Layer → Resource */}
262 <div className="service-swap-stage">
263 <div className="service-swap-stage__node service-swap-stage__node--accent">
264 <svg
265 width="22"
266 height="22"
267 viewBox="0 0 32 32"
268 fill="none"
269 aria-hidden
270 >
271 <rect
272 x="4"
273 y="6"
274 width="24"
275 height="20"
276 rx="4"
277 fill="rgba(184,138,74,0.15)"
278 stroke="var(--alc-accent-deep)"
279 strokeWidth="1.5"
280 />
281 <path
282 d="M9 13 H23 M9 17 H19 M9 21 H21"
283 stroke="var(--alc-accent-deep)"
284 strokeWidth="1.4"
285 strokeLinecap="round"
286 />
287 </svg>
288 <div className="service-swap-stage__label">Sessions</div>
289 <div className="service-swap-stage__sub">Context.Service</div>
290 </div>
291
292 <div
293 className="service-swap-stage__edge service-swap-stage__edge--down"
294 style={{ borderColor: active.color }}
295 >
296 <span
297 className="service-swap-stage__edge-label"
298 style={{ color: active.color, borderColor: active.color }}
299 >
300 Layer.effect
301 </span>
302 </div>
303
304 <div
305 key={`layer-${idx}`}
306 className="service-swap-stage__node layer-swap-node"
307 style={{
308 borderColor: active.color,
309 boxShadow: `0 0 0 2px ${tint(active.color, 0.12)}`,
310 }}
311 >
312 <svg
313 width="22"
314 height="22"
315 viewBox="0 0 32 32"
316 fill="none"
317 aria-hidden
318 >
319 <path
320 d="M16 4 L28 10 L16 16 L4 10 Z"
321 fill={tint(active.color, 0.18)}
322 stroke={active.color}
323 strokeWidth="1.4"
324 strokeLinejoin="round"
325 />
326 <path
327 d="M4 16 L16 22 L28 16"
328 stroke={active.color}
329 strokeWidth="1.4"
330 fill="none"
331 strokeLinejoin="round"
332 opacity="0.85"
333 />
334 <path
335 d="M4 22 L16 28 L28 22"
336 stroke={active.color}
337 strokeWidth="1.4"
338 fill="none"
339 strokeLinejoin="round"
340 opacity="0.55"
341 />
342 </svg>
343 <div
344 className="service-swap-stage__label"
345 style={{ color: active.color }}
346 >
347 {active.layer}
348 </div>
349 <div className="service-swap-stage__sub">Layer</div>
350 </div>
351
352 <div
353 className="service-swap-stage__edge service-swap-stage__edge--down"
354 style={{ borderColor: active.color }}
355 >
356 <span
357 className="service-swap-stage__edge-label"
358 style={{ color: active.color, borderColor: active.color }}
359 >
360 {active.bindCall}
361 </span>
362 </div>
363
364 <div
365 key={`res-${idx}`}
366 className="service-swap-stage__node layer-swap-node"
367 style={{ borderColor: active.color }}
368 >
369 <ResourceIcon kind={active.kind} color={active.color} />
370 <div className="service-swap-stage__label">
371 {active.resourceLabel}
372 </div>
373 <div className="service-swap-stage__sub">{active.resourceSub}</div>
374 </div>
375
376 <div className="service-swap-stage__steps" aria-hidden>
377 {IMPLS.map((_, i) => (
378 <span
379 key={i}
380 className={`service-swap-stage__dot${i === idx ? " is-active" : ""}`}
381 style={i === idx ? { background: active.color } : undefined}
382 />
383 ))}
384 </div>
385 </div>
386 </div>
387 );
388}