1---
2import Marketing from "../layouts/Marketing.astro";
3import Button from "../components/marketing/Button.astro";
4import AlchemicalDivider from "../components/marketing/AlchemicalDivider.astro";
5import Yantra from "../brand/Yantra.astro";
6import TestLoop from "../components/compare/TestLoop.astro";
7import OneLine from "../components/compare/OneLine.astro";
8
9const TOOLS = ["Alchemy", "SST", "Pulumi", "Terraform", "AWS CDK"] as const;
10
11// One row per dimension; the first cell is Alchemy.
12const ROWS: {
13 label: string;
14 cells: [string, string, string, string, string];
15}[] = [
16 {
17 label: "Language",
18 cells: [
19 "TypeScript + Effect",
20 "TypeScript",
21 "TypeScript, Python, Go, C#, Java",
22 "HCL",
23 "TypeScript, Python, Java, C#, Go",
24 ],
25 },
26 {
27 label: "Built on",
28 cells: [
29 "Pure TypeScript, end to end",
30 "Pulumi, mostly bridged Terraform providers",
31 "A Go engine, many providers bridged from Terraform",
32 "A Go engine and Go provider plugins",
33 "CloudFormation, a managed engine inside AWS",
34 ],
35 },
36 {
37 label: "App code in the same program",
38 cells: ["Yes, as typed Effects and Layers", "No", "No", "No", "No"],
39 },
40 {
41 label: "Permissions",
42 cells: [
43 "Derived from the bindings your code uses",
44 "Granted by link",
45 "Unenforced",
46 "Unenforced",
47 "Granted by grant\u2060*\u2060() calls",
48 ],
49 },
50 {
51 label: "Runs locally",
52 cells: [
53 "Emulated on your machine",
54 "No, it deploys to the cloud",
55 "No",
56 "No",
57 "No",
58 ],
59 },
60 {
61 label: "Tests against real infrastructure",
62 cells: [
63 "Deploy, assert, destroy from bun test or Vitest",
64 "No",
65 "No",
66 "No",
67 "No",
68 ],
69 },
70];
71
72// ---------- engine: what sits between your code and the cloud ----------
73type Layer = { name: string; sub: string };
74const ENGINES: {
75 tool: string;
76 file: string;
77 layers: Layer[];
78 note: string;
79 ours?: boolean;
80}[] = [
81 {
82 tool: "Alchemy",
83 file: "alchemy.run.ts",
84 ours: true,
85 note: "1 runtime · TypeScript",
86 layers: [{ name: "Alchemy", sub: "TypeScript + Effect" }],
87 },
88 {
89 tool: "SST",
90 file: "sst.config.ts",
91 note: "3 layers · TypeScript → Go",
92 layers: [
93 { name: "SST", sub: "Go CLI" },
94 { name: "Pulumi engine", sub: "Go" },
95 { name: "Terraform provider", sub: "Go, through a bridge" },
96 ],
97 },
98 {
99 tool: "Pulumi",
100 file: "index.ts",
101 note: "2 layers · gRPC between them",
102 layers: [
103 { name: "Pulumi engine", sub: "Go" },
104 {
105 name: "Provider plugin",
106 sub: "often a bridged Terraform provider",
107 },
108 ],
109 },
110 {
111 tool: "Terraform",
112 file: "main.tf",
113 note: "2 layers · HCL → Go",
114 layers: [
115 { name: "Terraform core", sub: "Go" },
116 { name: "Provider plugin", sub: "Go" },
117 ],
118 },
119];
120// Nested boxes, outermost first (built as HTML because Astro templates
121// can't recurse inline). All strings are constants above.
122const nest = (layers: Layer[]): string =>
123 layers.reduceRight(
124 (inner, l, i) =>
125 `<div class="dg-layer" style="--depth:${i}"><span class="dg-layer__name">${l.name}<small>${l.sub}</small></span>${inner}</div>`,
126 "",
127 );
128
129// ---------- local dev: where one request goes ----------
130// Each trace is a request's path, one hop per node, on one of two lanes.
131// Every hop across the network line is marked; Alchemy's never leaves the
132// machine.
133// `hold` pauses the dot on that hop for that many seconds and shows a
134// progress bar on its box (e.g. a deploy the request has to wait for).
135// Lanes: "m" your machine, "c" cloud, "b" on the network boundary (a step
136// that runs locally but pushes to the cloud, like a deploy).
137// `key` identifies the same box across Alchemy's dev modes so it can animate.
138type Hop = {
139 label: string;
140 lane: "m" | "c" | "b";
141 hold?: number;
142 key?: string;
143};
144const TRACE_H = 170;
145const NET_Y_ = 85;
146const LANE_Y = { m: 46, c: 124, b: NET_Y_ };
147const NET_Y = NET_Y_;
148const STEP = 128;
149const X0 = 70;
150type DevMode = {
151 id: string;
152 headline: string;
153 sub: string;
154 hops: Hop[];
155};
156// Alchemy's row is interactive: drag Lambda or DynamoDB across the network
157// line (`remote()` in `alchemy dev`). Both in the cloud means every edit is a
158// deploy. Boxes are matched across modes by `key`.
159const ALCHEMY_MODES: DevMode[] = [
160 {
161 id: "local",
162 headline: "0 round trips",
163 sub: "Runs offline. Edits reload in place.",
164 hops: [
165 { label: "browser", lane: "m", key: "src" },
166 { label: "Lambda · Docker", lane: "m", key: "fn" },
167 { label: "DynamoDB · floci", lane: "m", key: "db" },
168 ],
169 },
170 {
171 id: "remote-data",
172 headline: "1 round trip",
173 sub: "remote() puts DynamoDB in the real cloud. Your Lambda still runs locally.",
174 hops: [
175 { label: "browser", lane: "m", key: "src" },
176 { label: "Lambda · Docker", lane: "m", key: "fn" },
177 { label: "DynamoDB", lane: "c", key: "db" },
178 ],
179 },
180 {
181 id: "remote-fn",
182 headline: "2 round trips",
183 sub: "remote() deploys the Lambda for real. It still reads and writes your local DynamoDB.",
184 hops: [
185 { label: "browser", lane: "m", key: "src" },
186 { label: "Lambda", lane: "c", key: "fn" },
187 { label: "DynamoDB · floci", lane: "m", key: "db" },
188 ],
189 },
190 {
191 id: "deploy",
192 headline: "Deploy each edit",
193 sub: "With the whole stack in remote(), dev redeploys it for real as you edit.",
194 hops: [
195 { label: "edit", lane: "m", key: "src" },
196 // Half the Terraform row's hold: no engine or provider plugins to start.
197 { label: "deploy", lane: "b", hold: 2.4, key: "deploy" },
198 { label: "Lambda", lane: "c", key: "fn" },
199 { label: "DynamoDB", lane: "c", key: "db" },
200 ],
201 },
202];
203const TRACES: {
204 tool: string;
205 ours?: boolean;
206 headline: string;
207 sub: string;
208 hops: Hop[];
209 modes?: DevMode[];
210}[] = [
211 { tool: "Alchemy", ours: true, ...ALCHEMY_MODES[0]!, modes: ALCHEMY_MODES },
212 {
213 tool: "SST",
214 headline: "3 round trips",
215 sub: "Per request, through a deployed stage. Needs a connection.",
216 hops: [
217 { label: "browser", lane: "m" },
218 { label: "API Gateway", lane: "c" },
219 { label: "Lambda stub", lane: "c" },
220 { label: "your code", lane: "m" },
221 { label: "DynamoDB", lane: "c" },
222 ],
223 },
224 {
225 tool: "Pulumi & Terraform",
226 headline: "Deploy first",
227 sub: "No local runtime. Every change is a redeploy.",
228 hops: [
229 { label: "edit", lane: "m" },
230 { label: "deploy", lane: "b", hold: 4.8 },
231 { label: "Lambda", lane: "c" },
232 { label: "DynamoDB", lane: "c" },
233 ],
234 },
235];
236const SPAN = 520;
237const traceGeom = (hops: Hop[], opts: { fullSpan?: boolean } = {}) => {
238 // Short traces spread out; long ones use the default step. Alchemy's
239 // modes always span the full width so their paths share start and end.
240 const step = opts.fullSpan
241 ? SPAN / Math.max(1, hops.length - 1)
242 : Math.max(STEP, Math.min(190, SPAN / Math.max(1, hops.length - 1)));
243 const pts = hops.map((h, i) => ({
244 ...h,
245 x: X0 + i * step,
246 y: LANE_Y[h.lane],
247 w: Math.max(h.label.length * 6.9 + 24, h.hold ? 96 : 0),
248 }));
249 const segs = pts.slice(1).map((b, i) => {
250 const a = pts[i]!;
251 return a.lane === b.lane
252 ? `M ${a.x} ${a.y} L ${b.x} ${b.y}`
253 : `M ${a.x} ${a.y} C ${a.x + step / 2} ${a.y}, ${b.x - step / 2} ${b.y}, ${b.x} ${b.y}`;
254 });
255 const crosses = (a: Hop, b: Hop) =>
256 a.lane !== b.lane && a.lane !== "b" && b.lane !== "b";
257 const crossings = pts
258 .slice(1)
259 .flatMap((b, i) =>
260 crosses(pts[i]!, b) ? [{ x: (pts[i]!.x + b.x) / 2, y: NET_Y }] : [],
261 );
262
263 // Motion timing for the traveling dot: constant speed, except it slows
264 // to a crawl for CROSS_WINDOW px either side of each network crossing.
265 const bezierLength = (a: (typeof pts)[number], b: (typeof pts)[number]) => {
266 const p = [
267 [a.x, a.y],
268 [a.x + step / 2, a.y],
269 [b.x - step / 2, b.y],
270 [b.x, b.y],
271 ] as const;
272 let len = 0;
273 let prev = p[0];
274 for (let k = 1; k <= 32; k++) {
275 const t = k / 32,
276 u = 1 - t;
277 const q = [0, 1].map(
278 (d) =>
279 u * u * u * p[0][d]! +
280 3 * u * u * t * p[1][d]! +
281 3 * u * t * t * p[2][d]! +
282 t * t * t * p[3][d]!,
283 ) as [number, number];
284 len += Math.hypot(q[0] - prev[0], q[1] - prev[1]);
285 prev = q;
286 }
287 return len;
288 };
289 let total = 0;
290 const crossAt: number[] = [];
291 const nodeAt = [0];
292 pts.slice(1).forEach((b, i) => {
293 const a = pts[i]!;
294 const len =
295 a.lane === b.lane
296 ? Math.hypot(b.x - a.x, b.y - a.y)
297 : bezierLength(a, b);
298 // Lane changes are symmetric, so the crossing is at half their length.
299 if (crosses(a, b)) crossAt.push(total + len / 2);
300 total += len;
301 nodeAt.push(total);
302 });
303 const CROSS_WINDOW = 16;
304 const SLOW = 6;
305 const SPEED = 170; // px/s away from crossings
306 // Breakpoints along the path, each with how the dot covers the span that
307 // ends there: "slow" through a crossing, "hold" (zero distance) at a hop.
308 type Mark = { at: number; kind: "move" | "slow" | "hold"; secs?: number };
309 const marks: Mark[] = [
310 ...crossAt.flatMap((d): Mark[] => [
311 { at: d - CROSS_WINDOW, kind: "move" },
312 { at: d + CROSS_WINDOW, kind: "slow" },
313 ]),
314 ...pts.flatMap((p, i): Mark[] =>
315 p.hold
316 ? [
317 { at: nodeAt[i]!, kind: "move" },
318 { at: nodeAt[i]!, kind: "hold", secs: p.hold },
319 ]
320 : [],
321 ),
322 { at: total, kind: "move" },
323 ].sort((a, b) => a.at - b.at || (a.kind === "hold" ? 1 : -1));
324 const points = [0];
325 const times = [0];
326 const holds: { hop: number; start: number; secs: number }[] = [];
327 for (const m of marks) {
328 const prevAt = points[points.length - 1]!;
329 const prevT = times[times.length - 1]!;
330 const dt =
331 m.kind === "hold"
332 ? m.secs!
333 : ((m.at - prevAt) / SPEED) * (m.kind === "slow" ? SLOW : 1);
334 if (m.kind === "hold") {
335 holds.push({
336 hop: nodeAt.indexOf(m.at),
337 start: prevT,
338 secs: m.secs!,
339 });
340 }
341 points.push(m.at);
342 times.push(prevT + dt);
343 }
344 const dur = times[times.length - 1]!;
345 const motion = {
346 dur: `${dur.toFixed(2)}s`,
347 keyPoints: points.map((m) => (m / total).toFixed(4)).join(";"),
348 keyTimes: times.map((t) => (t / dur).toFixed(4)).join(";"),
349 };
350 // A progress bar on each held hop fills while the dot waits there, stays
351 // full until the loop restarts, then resets.
352 const bars = holds.map((h) => ({
353 hop: h.hop,
354 values: "0;0;1;1",
355 keyTimes: [0, h.start / dur, (h.start + h.secs) / dur, 1]
356 .map((t) => t.toFixed(4))
357 .join(";"),
358 }));
359 return { pts, path: segs.join(" "), crossings, motion, bars };
360};
361const laneName = (l: Hop["lane"]) =>
362 l === "m"
363 ? "your machine"
364 : l === "c"
365 ? "cloud"
366 : "on the network boundary";
367const traceLabel = (tool: string, hops: Hop[], crossings: number) =>
368 `${tool}: ${hops.map((h) => `${h.label} (${laneName(h.lane)})`).join(", then ")}. ${crossings} crossings of the network.`;
369// Everything the client script needs to redraw Alchemy's row per mode.
370// Every mode has a box for every slot; a slot a mode doesn't use becomes a
371// hidden "ghost" sitting on that mode's path, so it can grow out of it.
372const ALCHEMY_SLOTS = ["src", "deploy", "fn", "db"];
373type Slot = {
374 x: number;
375 y: number;
376 w: number;
377 label: string;
378 shown: boolean;
379 bar: string | null;
380};
381const ALCHEMY_MODE_DATA = ALCHEMY_MODES.map((m) => {
382 const g = traceGeom(m.hops, { fullSpan: true });
383 const shown = ALCHEMY_SLOTS.map((key) => {
384 const i = m.hops.findIndex((h) => h.key === key);
385 if (i < 0) return null;
386 const p = g.pts[i]!;
387 const bar = g.bars.find((b) => b.hop === i);
388 return {
389 x: p.x,
390 y: p.y,
391 w: p.w,
392 label: p.label,
393 shown: true,
394 bar: bar?.keyTimes ?? null,
395 };
396 });
397 const slots: Slot[] = shown.map((s, i) => {
398 if (s) return s;
399 const prev = shown.slice(0, i).reverse().find(Boolean)!;
400 const next = shown.slice(i + 1).find(Boolean)!;
401 // The midpoint of a symmetric curve between its neighbors.
402 return {
403 x: (prev.x + next.x) / 2,
404 y: (prev.y + next.y) / 2,
405 w: 24,
406 label: "",
407 shown: false,
408 bar: null,
409 };
410 });
411 // The route as one cubic per slot gap (same count in every mode), so a
412 // mode switch can morph control points instead of re-deriving curves. A
413 // hidden slot splits its neighbors' curve exactly at t = 0.5, which keeps
414 // the drawn shape identical to the mode's real path.
415 type Cubic = number[]; // x0 y0 c1x c1y c2x c2y x1 y1
416 const cubic = (a: Slot, b: Slot): Cubic => {
417 const h = (b.x - a.x) / 2;
418 return [a.x, a.y, a.x + h, a.y, b.x - h, b.y, b.x, b.y];
419 };
420 const split = ([x0, y0, ax, ay, bx, by, x1, y1]: Cubic): [Cubic, Cubic] => {
421 const mid = (p: number, q: number) => (p + q) / 2;
422 const [m1x, m1y] = [mid(x0, ax), mid(y0, ay)];
423 const [m2x, m2y] = [mid(ax, bx), mid(ay, by)];
424 const [m3x, m3y] = [mid(bx, x1), mid(by, y1)];
425 const [n1x, n1y] = [mid(m1x, m2x), mid(m1y, m2y)];
426 const [n2x, n2y] = [mid(m2x, m3x), mid(m2y, m3y)];
427 const [px, py] = [mid(n1x, n2x), mid(n1y, n2y)];
428 return [
429 [x0, y0, m1x, m1y, n1x, n1y, px, py],
430 [px, py, n2x, n2y, m3x, m3y, x1, y1],
431 ];
432 };
433 const segs: Cubic[] = [];
434 for (let i = 0; i < slots.length - 1;) {
435 const a = slots[i]!;
436 const b = slots[i + 1]!;
437 if (b.shown) {
438 segs.push(cubic(a, b));
439 i += 1;
440 } else {
441 segs.push(...split(cubic(a, slots[i + 2]!)));
442 i += 2;
443 }
444 }
445 return {
446 headline: m.headline,
447 sub: m.sub,
448 local: m.hops.every((h) => h.lane === "m"),
449 lanes: Object.fromEntries(
450 m.hops.flatMap((h) => (h.key ? [[h.key, h.lane]] : [])),
451 ),
452 aria: traceLabel("Alchemy", m.hops, g.crossings.length),
453 path: g.path,
454 segs: segs.map((c) => c.map((n) => +n.toFixed(2))),
455 crossings: g.crossings,
456 motion: g.motion,
457 slots,
458 };
459});
460
461---
462
463<Marketing
464 title="Alchemy vs SST, Pulumi & Terraform"
465 description="How Alchemy compares to SST, Pulumi, and Terraform: pure TypeScript and Effect instead of wrapped Go engines, infrastructure and app code in one type-checked program, true local emulation, and deploy-test-destroy loops built for agents."
466>
467 <!-- ───────────────── HERO ───────────────── -->
468 <section class="cmp-hero cmp-pat-1">
469 <div class="cmp-container">
470 <Yantra class="cmp-hero__mark" size={40} />
471 <p class="cmp-eyebrow">Compare</p>
472 <h1 class="cmp-hero__title" data-copy>
473 Alchemy vs existing IaC solutions
474 </h1>
475 <p class="cmp-hero__lede" data-copy>
476 Most existing IaC solutions wrap terraform or are managed services in some way. Alchemy ships its own IaC engine in TypeScript, opening the door for more fully integrated features: <a href="#testing">testing</a>, <a href="#engine">engine</a>, <a href="#bindings">bindings</a>, <a href="#permissions">permissions</a>, and <a href="#local">local dev</a>.
477 </p>
478 </div>
479 </section>
480
481 <!-- ───────────────── AT A GLANCE ───────────────── -->
482 <section class="cmp-section cmp-pat-2" aria-labelledby="glance">
483 <div class="cmp-container">
484 <h2 class="cmp-h2" id="glance" data-copy>
485 At a glance
486 </h2>
487 <div
488 class="cmp-table-wrap"
489 tabindex="0"
490 role="region"
491 aria-label="Comparison table"
492 >
493 <table class="cmp-table">
494 <thead>
495 <tr>
496 <td></td>
497 {TOOLS.map((t, i) => (
498 <th
499 scope="col"
500 class:list={[i === 0 && "is-ours"]}
501 >
502 {i === 0 ? <Yantra size={18} /> : null}
503 {t}
504 </th>
505 ))}
506 </tr>
507 </thead>
508 <tbody>
509 {ROWS.map((row) => (
510 <tr>
511 <th scope="row">{row.label}</th>
512 {row.cells.map((c, i) => (
513 <td
514 class:list={[
515 i === 0 && "is-ours",
516 (c.startsWith("No") ||
517 c === "Unenforced") &&
518 "is-no",
519 ]}
520 >
521 {c}
522 </td>
523 ))}
524 </tr>
525 ))}
526 </tbody>
527 </table>
528 </div>
529 </div>
530 </section>
531
532 <AlchemicalDivider variant="triangle" class="cmp-divider" />
533
534 <!-- ───────────────── TESTING ───────────────── -->
535 <section
536 class="cmp-section cmp-pat-1"
537 id="testing"
538 aria-labelledby="testing-h"
539 >
540 <div class="cmp-container">
541 <h2 class="cmp-h2" id="testing-h" data-copy>
542 A complete iteration loop for agents
543 </h2>
544 <p class="cmp-lede" data-copy>
545 Alchemy Test deploys a real stack, runs tests on that deployment, then tears it down all from the test runner you already use. All agent's have to do to test against real infrastructure is make a code change, then run a test file; this keeps the loop extremely tight and lets ai agents move fast without breaking. Our <a href="https://github.com/alchemy-run/alchemy/tree/main/examples/cloudflare-preview-benchmark"> preview benchmark </a> takes 17 seconds for 1 full loop, <strong>no competitor even has a full loop</strong>.
546 </p>
547 <TestLoop />
548 </div>
549 </section>
550
551 <AlchemicalDivider variant="square" class="cmp-divider" />
552
553 <!-- ───────────────── ENGINE ───────────────── -->
554 <section
555 class="cmp-section cmp-pat-3"
556 id="engine"
557 aria-labelledby="engine-h"
558 >
559 <div class="cmp-container">
560 <h2 class="cmp-h2" id="engine-h" data-copy>
561 Its own engine, not a wrapper
562 </h2>
563 <p class="cmp-lede" data-copy>
564 Every competitor has a core and providers have to talk to that
565 core over a bridge, in some cases even 2. Alchemy just imports
566 providers as typescript and runs them directly, this means
567 alchemy can just deploy resources instead of waiting on events.
568 </p>
569 <div class="dg-engine">
570 {ENGINES.map((e) => (
571 <figure
572 class:list={["dg-engine__tool", e.ours && "is-ours"]}
573 >
574 <figcaption>{e.tool}</figcaption>
575 <span class="dg-pill">{e.file}</span>
576 <span class="dg-down" aria-hidden="true" />
577 <div class="dg-nest" set:html={nest(e.layers)} />
578 <span class="dg-down" aria-hidden="true" />
579 <span class="dg-pill dg-pill--cloud">Cloud API</span>
580 <p class="dg-note">{e.note}</p>
581 </figure>
582 ))}
583 </div>
584 </div>
585 </section>
586
587 <AlchemicalDivider variant="pentagon" class="cmp-divider" />
588
589 <!-- ───────────────── BINDINGS ───────────────── -->
590 <section
591 class="cmp-section cmp-pat-4"
592 id="bindings"
593 aria-labelledby="bindings-h"
594 >
595 <div class="cmp-container">
596 <h2 class="cmp-h2" id="bindings-h" data-copy>
597 One line does three jobs
598 </h2>
599 <p class="cmp-lede" data-copy>
600 To read a table, a function needs three things that usually
601 live in three different places: a permission, the table's
602 name, and a client. In Alchemy the function's code asks for
603 the capability, and that one line produces all three: its
604 statement in the IAM policy, the table's name, and a typed
605 client.
606 </p>
607 <OneLine />
608 </div>
609 </section>
610
611 <AlchemicalDivider variant="hexagon" class="cmp-divider" />
612
613 <!-- ───────────────── PERMISSIONS ───────────────── -->
614 <section
615 class="cmp-section cmp-pat-5"
616 id="permissions"
617 aria-labelledby="permissions-h"
618 >
619 <div class="cmp-container">
620 <h2 class="cmp-h2" id="permissions-h" data-copy>
621 Principle of least privilege
622 </h2>
623 <p class="cmp-lede" data-copy>
624 In Alchemy permission policies are created from your code. This allows least privilege security policies to be inferred on the type level. SST provides link to automate common policy usecases, but are one size fits all, not lease privilege. Pulumi &amp; terraform offer no enforcement, so you have to manually maintain permission policies yourself.
625 </p>
626 <div class="dg-perm">
627 {[
628 {
629 tool: "Alchemy",
630 ours: true,
631 needed: 42,
632 granted: 42,
633 dashed: false,
634 code: "DynamoDB.GetItem(table)",
635 grant: "dynamodb:GetItem on table",
636 verdict: "Exact",
637 },
638 {
639 tool: "SST",
640 needed: 42,
641 granted: 60,
642 dashed: false,
643 code: "link: [table]",
644 grant: "dynamodb:* on table",
645 verdict: "Close",
646 },
647 {
648 tool: "Pulumi & Terraform",
649 needed: 42,
650 granted: 78,
651 dashed: true,
652 code: "a policy you write",
653 grant: "often dynamodb:* on *",
654 verdict: "Unenforced",
655 },
656 ].map((d) => (
657 <figure class:list={["dg-perm__tool", d.ours && "is-ours"]}>
658 <figcaption>{d.tool}</figcaption>
659 <svg
660 viewBox="0 0 180 180"
661 class="dg-perm__rings"
662 role="img"
663 aria-label={`${d.tool}: grants ${d.grant}`}
664 >
665 <circle
666 class:list={[
667 "dg-perm__granted",
668 d.dashed && "is-dashed",
669 ]}
670 cx="90"
671 cy="90"
672 r={d.granted}
673 />
674 <circle
675 class="dg-perm__needed"
676 cx="90"
677 cy="90"
678 r={d.needed}
679 />
680 <text
681 x="90"
682 y="94"
683 text-anchor="middle"
684 class="dg-perm__label"
685 >
686 needed
687 </text>
688 </svg>
689 <code class="dg-perm__code">{d.code}</code>
690 <p class="dg-perm__grant">
691 grants <code>{d.grant}</code>
692 </p>
693 <p
694 class:list={[
695 "dg-verdict",
696 d.ours && "dg-verdict--yes",
697 ]}
698 >
699 {d.verdict}
700 </p>
701 </figure>
702 ))}
703 </div>
704 <ul class="dg-legend" aria-label="Legend">
705 <li><span class="dg-legend__needed" aria-hidden="true"></span>what the code needs</li>
706 <li><span class="dg-legend__granted" aria-hidden="true"></span>granted, scoped by the tool</li>
707 <li><span class="dg-legend__manual" aria-hidden="true"></span>granted by a policy you maintain</li>
708 </ul>
709 </div>
710 </section>
711
712 <AlchemicalDivider variant="heptagon" class="cmp-divider" />
713
714 <!-- ───────────────── LOCAL DEV ───────────────── -->
715 <section class="cmp-section cmp-pat-6" id="local" aria-labelledby="local-h">
716 <div class="cmp-container">
717 <h2 class="cmp-h2" id="local-h" data-copy>
718 True emulation for faster dev loops
719 </h2>
720 <p class="cmp-lede" data-copy>
721 <code>alchemy dev</code> runs your stack entirely on your
722 machine, or in the cloud, or in a hybrid of both, you get to
723 choose. Our competitors lock you into one testing model and
724 force you to accept the compromises they chose.
725 <br />
726 Or in some cases they don't offer any dev tooling and you just
727 deploy every time like a sucker stuck in the past.
728 </p>
729 <div class="dg-traces">
730 {TRACES.map((t, ti) => {
731 const g = traceGeom(t.hops, { fullSpan: !!t.modes });
732 const mode0 = t.modes ? ALCHEMY_MODE_DATA[0]! : null;
733 const last = g.pts[g.pts.length - 1]!;
734 return (
735 <figure
736 class:list={["dg-trace", t.ours && "is-ours"]}
737 data-modes={
738 t.modes
739 ? JSON.stringify(ALCHEMY_MODE_DATA)
740 : undefined
741 }
742 >
743 <figcaption class="dg-trace__meta">
744 <span class="dg-trace__tool">{t.tool}</span>
745 {/* Alchemy's text changes with the drag: every variant sits
746 in the same grid cell (hidden) so the column is always
747 sized to the longest and never shifts. */}
748 <span class="dg-trace__headline dg-stack">
749 <span data-headline>{t.headline}</span>
750 {t.modes?.map((m) => (
751 <span class="dg-sizer" aria-hidden="true">
752 {m.headline}
753 </span>
754 ))}
755 </span>
756 <span class="dg-trace__sub dg-stack">
757 <span data-sub aria-live="polite">
758 {t.sub}
759 </span>
760 {t.modes?.map((m) => (
761 <span class="dg-sizer" aria-hidden="true">
762 {m.sub}
763 </span>
764 ))}
765 </span>
766 </figcaption>
767 <div
768 class="dg-trace__scroll"
769 tabindex="0"
770 role="region"
771 aria-label={`${t.tool} request path`}
772 >
773 <svg
774 viewBox={`0 0 660 ${TRACE_H}`}
775 role={t.modes ? "group" : "img"}
776 aria-label={traceLabel(
777 t.tool,
778 t.hops,
779 g.crossings.length,
780 )}
781 >
782 <rect
783 class="dg-lane dg-lane--m"
784 x="0"
785 y="0"
786 width="660"
787 height={NET_Y}
788 />
789 <rect
790 class="dg-lane dg-lane--c"
791 x="0"
792 y={NET_Y}
793 width="660"
794 height={TRACE_H - NET_Y}
795 />
796 <line
797 class="dg-net"
798 x1="0"
799 y1={NET_Y}
800 x2="660"
801 y2={NET_Y}
802 />
803 <text
804 class="dg-lane__label"
805 x="10"
806 y={NET_Y - 7}
807 >
808 YOUR MACHINE
809 </text>
810 <text
811 class="dg-lane__label"
812 x="10"
813 y={NET_Y + 15}
814 >
815 CLOUD
816 </text>
817 <text
818 class="dg-net__label"
819 x="650"
820 y={NET_Y - 6}
821 text-anchor="end"
822 >
823 network
824 </text>
825 <path
826 class="dg-trace__path"
827 d={g.path}
828 id={`dg-trace-${ti}`}
829 />
830 {t.modes && (
831 <circle
832 class="dg-trace__cross"
833 data-cross-template
834 r="6"
835 cx="-20"
836 cy="-20"
837 hidden
838 />
839 )}
840 <g data-crosses>
841 {g.crossings.map((c) => (
842 <circle
843 class="dg-trace__cross"
844 cx={c.x}
845 cy={c.y}
846 r="6"
847 />
848 ))}
849 </g>
850 {mode0
851 ? mode0.slots.map((p, si) => (
852 <g
853 class:list={[
854 "dg-hop",
855 "is-slot",
856 ALCHEMY_SLOTS[si] ===
857 "deploy" &&
858 "is-boundary",
859 !p.shown && "is-ghost",
860 ]}
861 data-hop={si}
862 {...(ALCHEMY_SLOTS[si] === "fn" ||
863 ALCHEMY_SLOTS[si] === "db"
864 ? {
865 "data-drag":
866 ALCHEMY_SLOTS[si],
867 tabindex: "0",
868 role: "button",
869 }
870 : {})}
871 style={`transform: translate(${p.x}px, ${p.y}px)`}
872 >
873 <rect
874 x={-p.w / 2}
875 y={
876 ALCHEMY_SLOTS[si] ===
877 "deploy"
878 ? -24
879 : -15
880 }
881 width={p.w}
882 height={
883 ALCHEMY_SLOTS[si] ===
884 "deploy"
885 ? 40
886 : 30
887 }
888 rx="8"
889 />
890 <text
891 y={
892 ALCHEMY_SLOTS[si] ===
893 "deploy"
894 ? -8
895 : 4.5
896 }
897 text-anchor="middle"
898 >
899 {p.label}
900 </text>
901 {ALCHEMY_SLOTS[si] ===
902 "deploy" && (
903 <g
904 class="dg-hop__bar"
905 transform="translate(-38 -2)"
906 >
907 <rect
908 class="dg-hop__track"
909 width="76"
910 height="4"
911 rx="2"
912 />
913 <rect
914 class="dg-hop__fill"
915 width="76"
916 height="4"
917 rx="2"
918 >
919 <animateTransform
920 attributeName="transform"
921 type="scale"
922 dur={
923 g.motion
924 .dur
925 }
926 repeatCount="indefinite"
927 calcMode="linear"
928 values="0 1;0 1;1 1;1 1"
929 keyTimes={
930 ALCHEMY_MODE_DATA.find(
931 (m) =>
932 m
933 .slots[
934 si
935 ]!
936 .bar,
937 )?.slots[
938 si
939 ]!.bar ??
940 "0;0;1;1"
941 }
942 data-bar
943 />
944 </rect>
945 </g>
946 )}
947 </g>
948 ))
949 : g.pts.map((p, pi) => {
950 const bar = g.bars.find(
951 (b) => b.hop === pi,
952 );
953 return (
954 <g
955 class:list={[
956 "dg-hop",
957 p.lane === "b" &&
958 "is-boundary",
959 ]}
960 data-hop={pi}
961 style={`transform: translate(${p.x}px, ${p.y}px)`}
962 >
963 <rect
964 x={-p.w / 2}
965 y={bar ? -24 : -15}
966 width={p.w}
967 height={bar ? 40 : 30}
968 rx="8"
969 />
970 <text
971 y={bar ? -8 : 4.5}
972 text-anchor="middle"
973 >
974 {p.label}
975 </text>
976 {bar && (
977 <g
978 class="dg-hop__bar"
979 transform={`translate(${-p.w / 2 + 10} -2)`}
980 >
981 <rect
982 class="dg-hop__track"
983 width={
984 p.w - 20
985 }
986 height="4"
987 rx="2"
988 />
989 <rect
990 class="dg-hop__fill"
991 width={
992 p.w - 20
993 }
994 height="4"
995 rx="2"
996 >
997 <animateTransform
998 attributeName="transform"
999 type="scale"
1000 dur={
1001 g
1002 .motion
1003 .dur
1004 }
1005 repeatCount="indefinite"
1006 calcMode="linear"
1007 values={bar.values
1008 .split(
1009 ";",
1010 )
1011 .map(
1012 (
1013 v,
1014 ) =>
1015 `${v} 1`,
1016 )
1017 .join(
1018 ";",
1019 )}
1020 keyTimes={
1021 bar.keyTimes
1022 }
1023 />
1024 </rect>
1025 </g>
1026 )}
1027 </g>
1028 );
1029 })}
1030 {/* Drawn after the nodes so it passes over them, never behind. */}
1031 <circle
1032 class="dg-trace__dot"
1033 r="4.5"
1034 data-dot
1035 >
1036 <animateMotion
1037 dur={g.motion.dur}
1038 repeatCount="indefinite"
1039 calcMode="linear"
1040 keyPoints={g.motion.keyPoints}
1041 keyTimes={g.motion.keyTimes}
1042 >
1043 <mpath href={`#dg-trace-${ti}`} />
1044 </animateMotion>
1045 </circle>
1046 {t.ours && (
1047 <text
1048 class="dg-trace__done"
1049 data-done
1050 x="652"
1051 y={LANE_Y.m - 26}
1052 text-anchor="end"
1053 >
1054 stays on your machine
1055 </text>
1056 )}
1057 {t.modes && (
1058 <g
1059 class="dg-hint"
1060 data-hint
1061 aria-hidden="true"
1062 >
1063 <defs>
1064 <marker
1065 id="dg-hint-head"
1066 viewBox="0 0 10 10"
1067 refX="6"
1068 refY="5"
1069 markerWidth="9"
1070 markerHeight="9"
1071 orient="auto"
1072 >
1073 <path
1074 class="dg-hint__head"
1075 d="M1 1.5 L7 5 L1.5 8.5"
1076 />
1077 </marker>
1078 </defs>
1079 <path
1080 class="dg-hint__arrow"
1081 d="M 398 131 C 376 124, 344 112, 334 70"
1082 marker-end="url(#dg-hint-head)"
1083 />
1084 <text
1085 x="406"
1086 y="146"
1087 transform="rotate(-3 406 146)"
1088 >
1089 drag me to the cloud!
1090 </text>
1091 </g>
1092 )}
1093 </svg>
1094 </div>
1095 </figure>
1096 );
1097 })}
1098 </div>
1099 <ul class="dg-legend" aria-label="Legend">
1100 <li><span class="dg-legend__cross" aria-hidden="true"></span>a trip across the network</li>
1101 </ul>
1102 </div>
1103 </section>
1104
1105 <AlchemicalDivider variant="circle" class="cmp-divider" />
1106
1107 <!-- ───────────────── MORE ───────────────── -->
1108 <section class="cmp-section cmp-pat-2" aria-labelledby="more">
1109 <div class="cmp-container">
1110 <h2 class="cmp-h2" id="more" data-copy>
1111 And a few more things
1112 </h2>
1113 <ul class="cmp-cards cmp-cards--3">
1114 <li class="cmp-card">
1115 <h3 data-copy>Providers built from the spec</h3>
1116 <p data-copy>
1117 Cloudflare, AWS, Fly, PlanetScale, Prisma, Axiom, Stripe, GitHub, Docker, and more. We are constantly adding more providers.
1118 </p>
1119 </li>
1120 <li class="cmp-card">
1121 <h3 data-copy>Runs anywhere TypeScript runs</h3>
1122 <p data-copy>
1123 Alchemy is just typescript, it can run on your machine, inside a cloudflare worker, or even in a browser.
1124 </p>
1125 </li>
1126 <li class="cmp-card">
1127 <h3 data-copy>Bring your own provider</h3>
1128 <p data-copy>
1129 A provider is just TypeScript: declare the resource's
1130 props, then write its create, update, and delete as
1131 plain functions. No Go plugin or bridge to build.{" "}
1132 <a href="/infrastructure-as-code/custom-provider">Write one</a>.
1133 </p>
1134 </li>
1135 </ul>
1136
1137 <div class="cmp-cta">
1138 <Button variant="primary" icon="arrow" href="/getting-started">
1139 <span data-copy>Start your project</span>
1140 </Button>
1141 <Button variant="secondary" href="/what-is-alchemy">
1142 <span data-copy>What is Alchemy?</span>
1143 </Button>
1144 </div>
1145 </div>
1146 </section>
1147</Marketing>
1148
1149<script>
1150 // SVG (SMIL) animations don't follow CSS, so reduced motion has to stop
1151 // them here: every diagram freezes on its first frame. Also re-applied
1152 // if the setting changes while the page is open.
1153 const motion = matchMedia("(prefers-reduced-motion: reduce)");
1154 const syncSvgMotion = () => {
1155 for (const svg of document.querySelectorAll<SVGSVGElement>("svg")) {
1156 if (!svg.querySelector("animate, animateMotion, animateTransform")) continue;
1157 if (motion.matches) {
1158 svg.pauseAnimations();
1159 svg.setCurrentTime(0);
1160 } else {
1161 svg.unpauseAnimations();
1162 }
1163 }
1164 };
1165 syncSvgMotion();
1166 motion.addEventListener("change", syncSvgMotion);
1167</script>
1168
1169<script>
1170 // Alchemy's draggable row in "True emulation". On a switch the
1171 // boxes glide to their new spots and the dotted route bends with them
1172 // (redrawn every frame from the live box positions); boxes a mode doesn't
1173 // use shrink into the line. Then the new route, crossings, and dot land.
1174 type Slot = {
1175 x: number;
1176 y: number;
1177 w: number;
1178 label: string;
1179 shown: boolean;
1180 bar: string | null;
1181 };
1182 type ModeData = {
1183 headline: string;
1184 sub: string;
1185 local: boolean;
1186 lanes: Record<string, "m" | "c" | "b">;
1187 aria: string;
1188 path: string;
1189 segs: number[][];
1190 crossings: { x: number; y: number }[];
1191 motion: { dur: string; keyPoints: string; keyTimes: string };
1192 slots: Slot[];
1193 };
1194 const MOVE_MS = 520;
1195 const ease = (t: number) => 1 - Math.pow(1 - t, 3);
1196 const draw = (segs: number[][]) =>
1197 segs
1198 .map(
1199 ([x0, y0, ax, ay, bx, by, x1, y1]) =>
1200 `M ${x0} ${y0} C ${ax} ${ay}, ${bx} ${by}, ${x1} ${y1}`,
1201 )
1202 .join(" ");
1203
1204 for (const fig of document.querySelectorAll<HTMLElement>(
1205 "figure[data-modes]",
1206 )) {
1207 const modes = JSON.parse(fig.dataset.modes!) as ModeData[];
1208 const svg = fig.querySelector("svg")!;
1209 const path = svg.querySelector<SVGPathElement>(".dg-trace__path")!;
1210 const crosses = svg.querySelector<SVGGElement>("[data-crosses]")!;
1211 const dotAnim = svg.querySelector<SVGAnimationElement>(
1212 "[data-dot] animateMotion",
1213 )!;
1214 const barAnim = svg.querySelector<SVGAnimationElement>("[data-bar]");
1215 const done = svg.querySelector<SVGTextElement>("[data-done]");
1216 const headline = fig.querySelector<HTMLElement>("[data-headline]")!;
1217 const sub = fig.querySelector<HTMLElement>("[data-sub]")!;
1218 const boxes = [...svg.querySelectorAll<SVGGElement>("[data-hop]")];
1219 // Cloned so new crossings keep the page's scoped styles.
1220 const crossTemplate = svg.querySelector<SVGCircleElement>(
1221 "[data-cross-template]",
1222 )!;
1223 const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
1224 let current = modes[0]!;
1225 let frame = 0;
1226
1227 const place = (g: SVGGElement, s: Slot, x = s.x, y = s.y) => {
1228 g.style.transform = `translate(${x}px, ${y}px)`;
1229 const rect = g.querySelector("rect")!;
1230 rect.setAttribute("x", String(-s.w / 2));
1231 rect.setAttribute("width", String(s.w));
1232 };
1233
1234 const land = (m: ModeData) => {
1235 path.setAttribute("d", m.path);
1236 crosses.replaceChildren(
1237 ...m.crossings.map((c) => {
1238 const el = crossTemplate.cloneNode() as SVGCircleElement;
1239 el.removeAttribute("data-cross-template");
1240 el.removeAttribute("hidden");
1241 el.setAttribute("cx", String(c.x));
1242 el.setAttribute("cy", String(c.y));
1243 return el;
1244 }),
1245 );
1246 dotAnim.setAttribute("dur", m.motion.dur);
1247 dotAnim.setAttribute("keyPoints", m.motion.keyPoints);
1248 dotAnim.setAttribute("keyTimes", m.motion.keyTimes);
1249 const bar = m.slots.find((s) => s.bar)?.bar;
1250 if (barAnim && bar) {
1251 barAnim.setAttribute("dur", m.motion.dur);
1252 barAnim.setAttribute("keyTimes", bar);
1253 }
1254 dotAnim.beginElement();
1255 barAnim?.beginElement();
1256 fig.classList.remove("is-switching");
1257 };
1258
1259 // The route between the current box positions: one cubic per slot gap,
1260 // split in half across a hidden slot (mirrors the server-side build).
1261 type Cubic = number[];
1262 const cubic = (a: Slot, b: Slot): Cubic => {
1263 const h = (b.x - a.x) / 2;
1264 return [a.x, a.y, a.x + h, a.y, b.x - h, b.y, b.x, b.y];
1265 };
1266 const split = ([x0, y0, ax, ay, bx, by, x1, y1]: Cubic): Cubic[] => {
1267 const mid = (p: number, q: number) => (p + q) / 2;
1268 const [m1x, m1y, m2x, m2y, m3x, m3y] = [
1269 mid(x0!, ax!), mid(y0!, ay!), mid(ax!, bx!),
1270 mid(ay!, by!), mid(bx!, x1!), mid(by!, y1!),
1271 ];
1272 const [n1x, n1y, n2x, n2y] = [
1273 mid(m1x, m2x), mid(m1y, m2y), mid(m2x, m3x), mid(m2y, m3y),
1274 ];
1275 const [px, py] = [mid(n1x, n2x), mid(n1y, n2y)];
1276 return [
1277 [x0!, y0!, m1x, m1y, n1x, n1y, px, py],
1278 [px, py, n2x, n2y, m3x, m3y, x1!, y1!],
1279 ];
1280 };
1281 const segsFor = (slots: Slot[]) => {
1282 const segs: Cubic[] = [];
1283 for (let i = 0; i < slots.length - 1; ) {
1284 if (slots[i + 1]!.shown) {
1285 segs.push(cubic(slots[i]!, slots[i + 1]!));
1286 i += 1;
1287 } else {
1288 segs.push(...split(cubic(slots[i]!, slots[i + 2]!)));
1289 i += 2;
1290 }
1291 }
1292 return segs;
1293 };
1294
1295 const hint = svg.querySelector<SVGGElement>("[data-hint]");
1296 const NET_Y = 85;
1297 const NAMES: Record<string, string> = {
1298 fn: "Lambda",
1299 db: "DynamoDB",
1300 };
1301 const laneOf = (key: string, m = current) =>
1302 m.lanes[key] === "c" ? "c" : "m";
1303 const describe = () =>
1304 boxes.forEach((g) => {
1305 const key = g.dataset.drag;
1306 if (!key) return;
1307 const inCloud = laneOf(key) === "c";
1308 g.setAttribute(
1309 "aria-label",
1310 `${NAMES[key]}, ${inCloud ? "in the cloud" : "on your machine"}. Press Enter to move it ${inCloud ? "to your machine" : "to the cloud"}.`,
1311 );
1312 });
1313 const target = (key: string, lane: "m" | "c") => {
1314 const fn = key === "fn" ? lane : laneOf("fn");
1315 const db = key === "db" ? lane : laneOf("db");
1316 return modes.find(
1317 (m) => laneOf("fn", m) === fn && laneOf("db", m) === db,
1318 )!;
1319 };
1320
1321 const switchTo = (next: ModeData, from: ModeData) => {
1322 cancelAnimationFrame(frame);
1323 if (next !== current) {
1324 hint?.classList.add("is-hidden");
1325 headline.textContent = next.headline;
1326 sub.textContent = next.sub;
1327 done?.classList.toggle("is-hidden", !next.local);
1328 svg.setAttribute("aria-label", next.aria);
1329 }
1330 current = next;
1331 describe();
1332 fig.classList.add("is-switching");
1333 boxes.forEach((g, i) => {
1334 const s = next.slots[i]!;
1335 g.classList.toggle("is-ghost", !s.shown);
1336 if (s.label) g.querySelector("text")!.textContent = s.label;
1337 const rect = g.querySelector("rect")!;
1338 rect.setAttribute("x", String(-s.w / 2));
1339 rect.setAttribute("width", String(s.w));
1340 });
1341 if (reduced) {
1342 boxes.forEach((g, i) => place(g, next.slots[i]!));
1343 land(next);
1344 return;
1345 }
1346 // Glide: interpolate every slot and every route control point, so the
1347 // line bends with the boxes and lands exactly on the new route.
1348 const t0 = performance.now();
1349 const tick = (now: number) => {
1350 const k = ease(Math.min(1, (now - t0) / MOVE_MS));
1351 boxes.forEach((g, i) => {
1352 const f = from.slots[i]!;
1353 const s = next.slots[i]!;
1354 place(g, s, f.x + (s.x - f.x) * k, f.y + (s.y - f.y) * k);
1355 });
1356 path.setAttribute(
1357 "d",
1358 draw(
1359 from.segs.map((seg, si) =>
1360 seg.map(
1361 (n, ni) => n + (next.segs[si]![ni]! - n) * k,
1362 ),
1363 ),
1364 ),
1365 );
1366 if (k < 1) frame = requestAnimationFrame(tick);
1367 else land(next);
1368 };
1369 frame = requestAnimationFrame(tick);
1370 };
1371
1372 // Drag Lambda or DynamoDB across the network line. While dragging, the
1373 // box follows the pointer and the route bends to it; on release the
1374 // row settles into whichever setup the boxes now describe.
1375 const toSvg = (e: PointerEvent) => {
1376 const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(
1377 svg.getScreenCTM()!.inverse(),
1378 );
1379 return { x: pt.x, y: pt.y };
1380 };
1381 let drag: {
1382 g: SVGGElement;
1383 i: number;
1384 key: string;
1385 dx: number;
1386 dy: number;
1387 slots: Slot[];
1388 } | null = null;
1389 for (const g of boxes) {
1390 const key = g.dataset.drag;
1391 if (!key) continue;
1392 const i = boxes.indexOf(g);
1393 g.addEventListener("pointerdown", (e) => {
1394 if (e.button !== 0) return;
1395 cancelAnimationFrame(frame);
1396 const s = current.slots[i]!;
1397 const p = toSvg(e);
1398 drag = {
1399 g, i, key,
1400 dx: p.x - s.x,
1401 dy: p.y - s.y,
1402 slots: current.slots.map((x) => ({ ...x })),
1403 };
1404 g.setPointerCapture(e.pointerId);
1405 fig.classList.add("is-switching", "is-dragging");
1406 hint?.classList.add("is-hidden");
1407 e.preventDefault();
1408 });
1409 g.addEventListener("pointermove", (e) => {
1410 if (!drag || drag.g !== g) return;
1411 const p = toSvg(e);
1412 const s = drag.slots[i]!;
1413 const home = current.slots[i]!;
1414 // Free vertically between the lanes, a little give sideways.
1415 s.y = Math.max(40, Math.min(130, p.y - drag.dy));
1416 s.x = home.x + Math.max(-24, Math.min(24, p.x - drag.dx - home.x));
1417 place(g, s);
1418 path.setAttribute("d", draw(segsFor(drag.slots)));
1419 g.classList.toggle("is-over", (s.y > NET_Y) !== (laneOf(key) === "c"));
1420 });
1421 const drop = () => {
1422 if (!drag || drag.g !== g) return;
1423 const s = drag.slots[i]!;
1424 const from = { ...current, slots: drag.slots, segs: segsFor(drag.slots) };
1425 drag = null;
1426 g.classList.remove("is-over");
1427 fig.classList.remove("is-dragging");
1428 switchTo(target(key, s.y > NET_Y ? "c" : "m"), from);
1429 };
1430 g.addEventListener("pointerup", drop);
1431 g.addEventListener("pointercancel", drop);
1432 g.addEventListener("keydown", (e) => {
1433 if (e.key !== "Enter" && e.key !== " ") return;
1434 e.preventDefault();
1435 switchTo(target(key, laneOf(key) === "c" ? "m" : "c"), current);
1436 });
1437 }
1438 describe();
1439 }
1440</script>
1441
1442<script>
1443 import { play } from "cuelume";
1444
1445 // Resources you can't move (anything but Alchemy's Lambda and DynamoDB)
1446 // give a little under the pointer, then snap back with a red flash and
1447 // cuelume's error sound. The first node (browser / edit) and deploy steps
1448 // aren't resources, so they're left alone.
1449 const GIVE = 7; // max offset in SVG units
1450 const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
1451 for (const svg of document.querySelectorAll<SVGSVGElement>(".dg-trace svg")) {
1452 svg.addEventListener("pointerdown", (e) => {
1453 const g = (e.target as Element).closest<SVGGElement>(
1454 ".dg-hop:not([data-drag]):not(.is-ghost):not(.is-boundary):not([data-hop='0'])",
1455 );
1456 if (!g || e.button !== 0) return;
1457 const toSvg = (ev: PointerEvent) =>
1458 new DOMPoint(ev.clientX, ev.clientY).matrixTransform(
1459 svg.getScreenCTM()!.inverse(),
1460 );
1461 const start = toSvg(e);
1462 const base = g.style.transform;
1463 let moved = false;
1464 g.classList.remove("is-snapping");
1465 g.setPointerCapture(e.pointerId);
1466 // Resists harder the further you pull, never past GIVE.
1467 const give = (d: number) => GIVE * Math.tanh(d / (GIVE * 3));
1468 const move = (ev: PointerEvent) => {
1469 const p = toSvg(ev);
1470 const dx = p.x - start.x;
1471 const dy = p.y - start.y;
1472 if (Math.hypot(dx, dy) > 3) moved = true;
1473 if (!reduced) {
1474 g.style.transform = `${base} translate(${give(dx)}px, ${give(dy)}px)`;
1475 }
1476 };
1477 const end = (ev: PointerEvent) => {
1478 g.removeEventListener("pointermove", move);
1479 g.removeEventListener("pointerup", end);
1480 g.removeEventListener("pointercancel", end);
1481 g.classList.add("is-snapping");
1482 g.style.transform = base;
1483 if (moved && ev.type === "pointerup") {
1484 g.classList.remove("is-refused");
1485 void g.getBoundingClientRect(); // restart the flash
1486 g.classList.add("is-refused");
1487 play("error");
1488 }
1489 };
1490 g.addEventListener("pointermove", move);
1491 g.addEventListener("pointerup", end);
1492 g.addEventListener("pointercancel", end);
1493 e.preventDefault();
1494 });
1495 svg.addEventListener("animationend", (e) => {
1496 (e.target as Element).closest(".dg-hop")?.classList.remove("is-refused");
1497 });
1498 svg.addEventListener("transitionend", (e) => {
1499 if (e.propertyName === "transform") {
1500 (e.target as Element).classList.remove("is-snapping");
1501 }
1502 });
1503 }
1504</script>
1505
1506<style>
1507 .cmp-container {
1508 max-width: 1080px;
1509 margin: 0 auto;
1510 }
1511 .cmp-hero,
1512 .cmp-section {
1513 position: relative;
1514 isolation: isolate;
1515 overflow: hidden;
1516 padding: 80px 32px;
1517 }
1518 .cmp-hero {
1519 padding-top: 96px;
1520 padding-bottom: 64px;
1521 }
1522 /* Faint Sri Yantra motifs, as on the landing page. */
1523 .cmp-hero::before,
1524 .cmp-section::before {
1525 content: "";
1526 position: absolute;
1527 top: 50%;
1528 right: -380px;
1529 z-index: -1;
1530 width: 1000px;
1531 height: 1000px;
1532 translate: 0 -50%;
1533 pointer-events: none;
1534 background: var(--alc-fg-1);
1535 opacity: 0.045;
1536 mask: var(--pattern) center / contain no-repeat;
1537 -webkit-mask: var(--pattern) center / contain no-repeat;
1538 }
1539 .cmp-hero::before {
1540 opacity: 0.07;
1541 }
1542 .cmp-pat-1 {
1543 --pattern: url("/patterns/yantra-1.svg");
1544 }
1545 .cmp-pat-2 {
1546 --pattern: url("/patterns/yantra-2.svg");
1547 }
1548 .cmp-pat-3 {
1549 --pattern: url("/patterns/yantra-3.svg");
1550 }
1551 .cmp-pat-4 {
1552 --pattern: url("/patterns/yantra-4.svg");
1553 }
1554 .cmp-pat-5 {
1555 --pattern: url("/patterns/yantra-5.svg");
1556 }
1557 .cmp-pat-6 {
1558 --pattern: url("/patterns/yantra-6.svg");
1559 }
1560 .cmp-pat-2::before,
1561 .cmp-pat-4::before,
1562 .cmp-pat-6::before {
1563 right: auto;
1564 left: -380px;
1565 }
1566 :global(.cmp-divider) {
1567 margin: 0 auto;
1568 }
1569
1570 /* ---------- hero ---------- */
1571 :global(.cmp-hero__mark) {
1572 display: block;
1573 margin-bottom: 20px;
1574 }
1575 .cmp-eyebrow {
1576 margin: 0 0 12px;
1577 font-family: var(--alc-font-mono);
1578 font-size: 12px;
1579 letter-spacing: 0.16em;
1580 text-transform: uppercase;
1581 color: var(--alc-accent-deep);
1582 }
1583 .cmp-hero__title {
1584 margin: 0 0 18px;
1585 max-width: 820px;
1586 font-family: var(--alc-font-serif);
1587 font-weight: 500;
1588 font-size: clamp(38px, 6vw, 60px);
1589 line-height: 1.05;
1590 letter-spacing: -0.015em;
1591 color: var(--alc-fg-1);
1592 }
1593 .cmp-hero__lede {
1594 margin: 0;
1595 max-width: 640px;
1596 font-family: var(--alc-font-serif);
1597 font-size: clamp(18px, 2.2vw, 22px);
1598 line-height: 1.4;
1599 color: var(--alc-fg-2);
1600 }
1601
1602 /* ---------- type ---------- */
1603 .cmp-h2 {
1604 margin: 0 0 14px;
1605 font-family: var(--alc-font-serif);
1606 font-weight: 500;
1607 font-size: clamp(30px, 4.5vw, 40px);
1608 line-height: 1.1;
1609 letter-spacing: -0.01em;
1610 color: var(--alc-fg-1);
1611 }
1612 .cmp-lede {
1613 margin: 0 0 28px;
1614 max-width: 680px;
1615 font-size: 16px;
1616 line-height: 1.65;
1617 color: var(--alc-fg-2);
1618 }
1619 .cmp-hero__lede a,
1620 .cmp-lede a,
1621 .cmp-card a {
1622 color: var(--alc-accent-deep);
1623 text-decoration: underline;
1624 text-underline-offset: 3px;
1625 }
1626
1627 /* ---------- comparison table ---------- */
1628 .cmp-table-wrap {
1629 overflow-x: auto;
1630 border: 1px solid var(--alc-hairline-2);
1631 border-radius: 14px;
1632 background: color-mix(in srgb, var(--alc-bg-elev-1) 70%, transparent);
1633 }
1634 .cmp-table-wrap:focus-visible {
1635 outline: 2px solid var(--alc-accent);
1636 outline-offset: 2px;
1637 }
1638 .cmp-table {
1639 width: 100%;
1640 table-layout: fixed;
1641 min-width: 960px;
1642 border-collapse: collapse;
1643 font-size: 16px;
1644 line-height: 1.5;
1645 }
1646 .cmp-table th,
1647 .cmp-table td {
1648 padding: 16px 18px;
1649 text-align: left;
1650 vertical-align: top;
1651 border-bottom: 1px solid var(--alc-hairline);
1652 color: var(--alc-fg-2);
1653 }
1654 .cmp-table tbody tr:last-child > * {
1655 border-bottom: 0;
1656 }
1657 .cmp-table thead th {
1658 font-family: var(--alc-font-serif);
1659 font-size: 21px;
1660 font-weight: 500;
1661 color: var(--alc-fg-1);
1662 vertical-align: bottom;
1663 }
1664 .cmp-table thead th :global(svg) {
1665 vertical-align: -3px;
1666 margin-right: 6px;
1667 }
1668 .cmp-table thead td,
1669 .cmp-table tbody th {
1670 width: 150px;
1671 font-weight: 600;
1672 color: var(--alc-fg-1);
1673 }
1674 /* Row labels stay in view while the table scrolls sideways. */
1675 .cmp-table tbody th,
1676 .cmp-table thead td {
1677 position: sticky;
1678 left: 0;
1679 z-index: 1;
1680 background: var(--alc-bg-elev-1);
1681 }
1682 .cmp-table .is-no {
1683 color: var(--alc-fg-3);
1684 }
1685 /* Alchemy's column is highlighted. */
1686 .cmp-table .is-ours {
1687 background: color-mix(in srgb, var(--alc-accent) 11%, transparent);
1688 color: var(--alc-fg-1);
1689 }
1690 .cmp-table thead .is-ours {
1691 border-top-left-radius: 0;
1692 }
1693
1694 /* ---------- split + cards ---------- */
1695 .cmp-split {
1696 display: grid;
1697 grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
1698 gap: 24px;
1699 align-items: start;
1700 }
1701 .cmp-cards {
1702 list-style: none;
1703 margin: 0;
1704 padding: 0;
1705 display: grid;
1706 gap: 16px;
1707 }
1708 .cmp-cards--2 {
1709 grid-template-columns: repeat(2, minmax(0, 1fr));
1710 }
1711 .cmp-cards--3 {
1712 grid-template-columns: repeat(3, minmax(0, 1fr));
1713 }
1714 .cmp-card {
1715 padding: 20px 22px;
1716 border: 1px solid var(--alc-hairline-2);
1717 border-radius: 14px;
1718 background: color-mix(in srgb, var(--alc-bg-elev-1) 70%, transparent);
1719 }
1720 .cmp-card h3 {
1721 margin: 0 0 8px;
1722 font-family: var(--alc-font-serif);
1723 font-weight: 500;
1724 font-size: 20px;
1725 line-height: 1.25;
1726 color: var(--alc-fg-1);
1727 }
1728 .cmp-card p {
1729 margin: 0;
1730 font-size: 14.5px;
1731 line-height: 1.6;
1732 color: var(--alc-fg-2);
1733 }
1734 .cmp-cards--stack .cmp-card {
1735 border-left: 3px solid var(--alc-accent);
1736 }
1737
1738 /* ---------- diagrams: shared ---------- */
1739 figure {
1740 margin: 0;
1741 }
1742 figcaption {
1743 font-family: var(--alc-font-serif);
1744 font-size: 20px;
1745 font-weight: 500;
1746 color: var(--alc-fg-1);
1747 }
1748 .dg-pill {
1749 display: inline-block;
1750 padding: 5px 10px;
1751 border: 1px dashed var(--alc-hairline-3);
1752 border-radius: 999px;
1753 font-family: var(--alc-font-mono);
1754 font-size: 12px;
1755 color: var(--alc-fg-2);
1756 background: var(--alc-bg);
1757 white-space: nowrap;
1758 }
1759 .dg-pill--cloud {
1760 border-style: dashed;
1761 color: var(--alc-fg-3);
1762 }
1763 .dg-note,
1764 .dg-verdict {
1765 margin: 12px 0 0;
1766 font-size: 13px;
1767 color: var(--alc-fg-3);
1768 }
1769 .dg-verdict {
1770 font-weight: 600;
1771 color: var(--alc-fg-2);
1772 }
1773 .dg-verdict--yes {
1774 color: var(--alc-accent-deep);
1775 }
1776 .dg-perm code,
1777 .cmp-lede code {
1778 font-family: var(--alc-font-mono);
1779 font-size: 0.92em;
1780 color: var(--alc-fg-1);
1781 }
1782
1783 /* ---------- engine: nested wrappers ---------- */
1784 .dg-engine {
1785 display: grid;
1786 grid-template-columns: repeat(4, minmax(0, 1fr));
1787 gap: 20px;
1788 align-items: start;
1789 }
1790 .dg-engine__tool {
1791 display: flex;
1792 flex-direction: column;
1793 align-items: center;
1794 text-align: center;
1795 }
1796 .dg-engine__tool figcaption {
1797 margin-bottom: 12px;
1798 }
1799 .dg-down {
1800 width: 0;
1801 height: 22px;
1802 border-left: 1.5px dashed var(--alc-hairline-3);
1803 }
1804 .is-ours .dg-down {
1805 border-left-color: var(--alc-accent);
1806 }
1807 .dg-nest {
1808 align-self: stretch;
1809 }
1810 .dg-nest :global(.dg-layer) {
1811 padding: 10px 10px 12px;
1812 border: 1.5px dashed var(--alc-hairline-3);
1813 border-radius: 14px;
1814 background: color-mix(
1815 in srgb,
1816 var(--alc-fg-1) calc(var(--depth) * 2%),
1817 transparent
1818 );
1819 }
1820 .dg-nest :global(.dg-layer .dg-layer) {
1821 margin-top: 10px;
1822 }
1823 .dg-nest :global(.dg-layer__name) {
1824 display: block;
1825 font-size: 13px;
1826 font-weight: 600;
1827 color: var(--alc-fg-1);
1828 }
1829 .dg-nest :global(.dg-layer__name small) {
1830 display: block;
1831 font-weight: 400;
1832 font-size: 11.5px;
1833 color: var(--alc-fg-3);
1834 }
1835 .is-ours .dg-nest :global(.dg-layer) {
1836 padding: 22px 10px;
1837 border-style: solid;
1838 border-color: var(--alc-accent);
1839 background: color-mix(in srgb, var(--alc-accent) 8%, transparent);
1840 }
1841
1842 /* ---------- permissions: needed vs granted ---------- */
1843 .dg-perm {
1844 display: grid;
1845 grid-template-columns: repeat(3, minmax(0, 1fr));
1846 gap: 20px;
1847 }
1848 .dg-perm__tool {
1849 display: flex;
1850 flex-direction: column;
1851 align-items: center;
1852 text-align: center;
1853 padding: 4px 0;
1854 }
1855 .dg-perm__rings {
1856 width: 180px;
1857 max-width: 100%;
1858 margin: 8px 0 10px;
1859 }
1860 .dg-perm__granted {
1861 fill: color-mix(in srgb, var(--alc-warn) 6%, transparent);
1862 stroke: var(--alc-warn);
1863 stroke-width: 1.5;
1864 }
1865 .dg-perm__granted.is-dashed {
1866 fill: color-mix(in srgb, var(--alc-danger) 10%, transparent);
1867 stroke: var(--alc-danger);
1868 stroke-dasharray: 5 5;
1869 }
1870 .dg-perm__needed {
1871 fill: color-mix(in srgb, var(--alc-accent) 14%, transparent);
1872 stroke: var(--alc-accent);
1873 stroke-width: 2;
1874 }
1875 .dg-perm__label {
1876 font: 600 12px var(--alc-font-sans);
1877 fill: var(--alc-fg-1);
1878 }
1879 .dg-perm__code {
1880 font-size: 12.5px;
1881 }
1882 .dg-perm__grant {
1883 margin: 6px 0 0;
1884 font-size: 13px;
1885 color: var(--alc-fg-3);
1886 }
1887 .dg-legend {
1888 display: flex;
1889 flex-wrap: wrap;
1890 align-items: center;
1891 gap: 8px 18px;
1892 margin: 16px 0 0;
1893 padding: 0;
1894 list-style: none;
1895 font-size: 13px;
1896 color: var(--alc-fg-3);
1897 }
1898 /* Each swatch stays on the same line as its label. */
1899 .dg-legend li {
1900 display: inline-flex;
1901 align-items: center;
1902 gap: 8px;
1903 }
1904 .dg-legend span {
1905 flex: none;
1906 display: inline-block;
1907 width: 12px;
1908 height: 12px;
1909 border-radius: 50%;
1910 }
1911 .dg-legend__needed {
1912 background: color-mix(in srgb, var(--alc-accent) 45%, transparent);
1913 border: 1.5px solid var(--alc-accent);
1914 }
1915 .dg-legend__granted {
1916 border: 1.5px solid var(--alc-warn);
1917 }
1918 #permissions .dg-legend__manual {
1919 border: 1.5px dashed var(--alc-danger);
1920 }
1921
1922 /* ---------- local dev: request traces ---------- */
1923 .dg-kicker {
1924 margin: 0 0 12px;
1925 font-family: var(--alc-font-mono);
1926 font-size: 11px;
1927 letter-spacing: 0.14em;
1928 text-transform: uppercase;
1929 color: var(--alc-fg-3);
1930 }
1931 .dg-traces {
1932 display: grid;
1933 gap: 14px;
1934 }
1935 .dg-trace {
1936 display: grid;
1937 grid-template-columns: 220px minmax(0, 1fr);
1938 align-items: center;
1939 gap: 20px;
1940 padding: 6px 0;
1941 }
1942 .dg-trace__meta {
1943 display: grid;
1944 gap: 4px;
1945 font: inherit;
1946 }
1947 .dg-trace__tool {
1948 font-family: var(--alc-font-serif);
1949 font-size: 17px;
1950 color: var(--alc-fg-2);
1951 }
1952 .dg-trace__headline {
1953 font-family: var(--alc-font-serif);
1954 font-size: 28px;
1955 line-height: 1.1;
1956 color: var(--alc-fg-1);
1957 }
1958 .is-ours .dg-trace__headline {
1959 color: var(--alc-accent-deep);
1960 }
1961 .dg-trace__sub {
1962 font-size: 13px;
1963 line-height: 1.45;
1964 color: var(--alc-fg-3);
1965 }
1966 .dg-stack {
1967 display: grid;
1968 }
1969 .dg-stack > * {
1970 grid-area: 1 / 1;
1971 }
1972 .dg-sizer {
1973 visibility: hidden;
1974 }
1975 .dg-sr {
1976 position: absolute;
1977 width: 1px;
1978 height: 1px;
1979 overflow: hidden;
1980 clip-path: inset(50%);
1981 white-space: nowrap;
1982 }
1983 /* Other resources can be grabbed, but refuse to move. The first node
1984 and deploy steps can't be grabbed at all. */
1985 .dg-hop:not(.is-ghost):not(.is-boundary):not([data-hop="0"]) {
1986 cursor: grab;
1987 }
1988 .dg-hop.is-snapping {
1989 transition: transform 260ms cubic-bezier(0.3, 1.6, 0.5, 1);
1990 }
1991 .dg-hop.is-refused > rect {
1992 animation: dg-refuse 650ms ease-out;
1993 }
1994 @keyframes dg-refuse {
1995 0%,
1996 45% {
1997 stroke: var(--alc-danger);
1998 stroke-width: 2.5;
1999 fill: color-mix(in srgb, var(--alc-danger) 14%, var(--alc-bg));
2000 }
2001 }
2002 /* Alchemy's Lambda and DynamoDB can be dragged across the network. */
2003 .dg-hop[data-drag] {
2004 cursor: grab;
2005 touch-action: none;
2006 outline: none;
2007 }
2008 .is-dragging,
2009 .is-dragging .dg-hop[data-drag] {
2010 cursor: grabbing;
2011 }
2012 .dg-hop[data-drag] > rect {
2013 transition:
2014 opacity 300ms ease,
2015 width 300ms ease,
2016 stroke 200ms ease,
2017 stroke-width 200ms ease;
2018 }
2019 .dg-hop[data-drag]:hover > rect,
2020 .dg-hop[data-drag]:focus-visible > rect {
2021 stroke: var(--alc-accent);
2022 stroke-width: 2;
2023 }
2024 .dg-hop[data-drag]:focus-visible > rect {
2025 stroke-dasharray: 3 3;
2026 }
2027 /* Past the line: the drop will move it. */
2028 .dg-hop.is-over > rect {
2029 stroke: var(--alc-accent);
2030 stroke-width: 2;
2031 fill: color-mix(in srgb, var(--alc-accent) 14%, var(--alc-bg));
2032 }
2033 .dg-hint {
2034 transition: opacity 300ms ease;
2035 pointer-events: none;
2036 }
2037 .dg-hint.is-hidden {
2038 opacity: 0;
2039 }
2040 .dg-hint text {
2041 font: 600 19px var(--alc-font-hand);
2042 fill: var(--alc-accent-deep);
2043 }
2044 .dg-hint__arrow,
2045 .dg-hint__head {
2046 fill: none;
2047 stroke: var(--alc-accent-deep);
2048 stroke-width: 1.6;
2049 stroke-linecap: round;
2050 stroke-linejoin: round;
2051 }
2052 /* Mode switch: nodes glide between lanes; path, crossings, and dot fade
2053 out while the new route is drawn. */
2054 .dg-hop.is-slot > rect,
2055 .dg-hop.is-slot > text,
2056 .dg-hop.is-slot > .dg-hop__bar {
2057 transition:
2058 opacity 300ms ease,
2059 width 300ms ease;
2060 }
2061 .dg-hop.is-ghost > rect,
2062 .dg-hop.is-ghost > text,
2063 .dg-hop.is-ghost > .dg-hop__bar {
2064 opacity: 0;
2065 }
2066 .dg-trace__path,
2067 [data-crosses],
2068 .dg-trace__dot,
2069 .dg-trace__done,
2070 .dg-trace__headline,
2071 .dg-trace__sub {
2072 transition: opacity 220ms ease;
2073 }
2074 .is-switching [data-crosses],
2075 .is-switching .dg-trace__dot,
2076 .dg-trace__done.is-hidden {
2077 opacity: 0;
2078 }
2079 .dg-trace__scroll {
2080 overflow-x: auto;
2081 border-radius: 10px;
2082 }
2083 .dg-trace__scroll:focus-visible {
2084 outline: 2px solid var(--alc-accent);
2085 outline-offset: 2px;
2086 }
2087 .dg-trace svg {
2088 display: block;
2089 width: 100%;
2090 min-width: 520px;
2091 border-radius: 10px;
2092 }
2093 .dg-lane {
2094 fill: none;
2095 }
2096 .dg-lane__label,
2097 .dg-net__label {
2098 font: 600 9.5px var(--alc-font-mono);
2099 letter-spacing: 0.12em;
2100 fill: var(--alc-fg-3);
2101 }
2102 .dg-net__label {
2103 font-weight: 400;
2104 letter-spacing: 0.04em;
2105 }
2106 .dg-net {
2107 stroke: var(--alc-hairline-3);
2108 stroke-width: 1;
2109 stroke-dasharray: 4 4;
2110 }
2111 .dg-trace__path {
2112 fill: none;
2113 stroke: var(--alc-fg-3);
2114 stroke-width: 1.75;
2115 stroke-dasharray: 5 6;
2116 }
2117 .is-ours .dg-trace__path {
2118 stroke: var(--alc-accent);
2119 }
2120 .dg-trace__cross {
2121 fill: var(--alc-danger);
2122 stroke: var(--alc-bg);
2123 stroke-width: 2;
2124 }
2125 .dg-hop > rect {
2126 fill: var(--alc-bg);
2127 stroke: var(--alc-hairline-3);
2128 }
2129 .is-ours .dg-hop > rect {
2130 stroke: color-mix(in srgb, var(--alc-accent) 70%, transparent);
2131 }
2132 .dg-hop text {
2133 font: 11.5px var(--alc-font-mono);
2134 fill: var(--alc-fg-1);
2135 }
2136 /* Straddles the network line: runs on your machine, lands in the cloud. */
2137 .dg-hop.is-boundary > rect {
2138 stroke: var(--alc-danger);
2139 stroke-dasharray: 4 3;
2140 }
2141 .dg-hop__track {
2142 fill: var(--alc-hairline-3);
2143 }
2144 .dg-hop__fill {
2145 fill: var(--alc-warn);
2146 }
2147 @media (prefers-reduced-motion: reduce) {
2148 .dg-hop__bar {
2149 display: none;
2150 }
2151 }
2152 .dg-trace__dot {
2153 fill: var(--alc-fg-1);
2154 /* A ring in the page color keeps it readable over node borders. */
2155 stroke: var(--alc-bg);
2156 stroke-width: 2;
2157 paint-order: stroke;
2158 }
2159 .is-ours .dg-trace__dot {
2160 fill: var(--alc-accent-deep);
2161 }
2162 .dg-trace__done {
2163 font: italic 12px var(--alc-font-sans);
2164 fill: var(--alc-accent-deep);
2165 }
2166 .dg-legend__cross {
2167 background: var(--alc-danger);
2168 }
2169 @media (prefers-reduced-motion: reduce) {
2170 .dg-trace__dot {
2171 display: none;
2172 }
2173 }
2174
2175 /* ---------- fit + cta ---------- */
2176 .cmp-cta {
2177 margin-top: 40px;
2178 display: flex;
2179 flex-wrap: wrap;
2180 gap: 12px;
2181 }
2182
2183 /* ---------- responsive ---------- */
2184 @media (max-width: 900px) {
2185 .cmp-split,
2186 .cmp-cards--2,
2187 .cmp-cards--3,
2188 .dg-trace {
2189 grid-template-columns: minmax(0, 1fr);
2190 }
2191 }
2192 @media (max-width: 640px) {
2193 .cmp-hero,
2194 .cmp-section {
2195 padding: 56px 20px;
2196 }
2197 /* Keep the comparisons side by side on phones, just smaller:
2198 seeing them together is the point. */
2199 .dg-engine {
2200 grid-template-columns: repeat(2, minmax(0, 1fr));
2201 gap: 24px 12px;
2202 }
2203 .dg-engine__tool figcaption {
2204 font-size: 16px;
2205 margin-bottom: 8px;
2206 }
2207 .dg-engine .dg-pill {
2208 padding: 3px 7px;
2209 font-size: 10px;
2210 }
2211 .dg-engine .dg-down {
2212 height: 14px;
2213 }
2214 .dg-nest :global(.dg-layer) {
2215 padding: 7px 5px 8px;
2216 border-radius: 10px;
2217 }
2218 .dg-nest :global(.dg-layer .dg-layer) {
2219 margin-top: 7px;
2220 }
2221 .dg-nest :global(.dg-layer__name) {
2222 font-size: 11px;
2223 }
2224 .dg-nest :global(.dg-layer__name small) {
2225 font-size: 9.5px;
2226 }
2227 .is-ours .dg-nest :global(.dg-layer) {
2228 padding: 14px 5px;
2229 }
2230 .dg-engine .dg-note {
2231 font-size: 11px;
2232 }
2233 .dg-perm {
2234 gap: 8px;
2235 }
2236 /* Two-line headers so all three rings line up. */
2237 .dg-perm__tool figcaption {
2238 display: flex;
2239 align-items: flex-end;
2240 justify-content: center;
2241 min-height: 2.6em;
2242 font-size: 14px;
2243 line-height: 1.3;
2244 }
2245 .dg-perm__rings {
2246 margin: 4px 0 6px;
2247 }
2248 .dg-perm .dg-perm__code {
2249 font-size: 10.5px;
2250 overflow-wrap: anywhere;
2251 }
2252 .dg-perm .dg-perm__grant {
2253 font-size: 10px;
2254 overflow-wrap: anywhere;
2255 }
2256 .dg-perm .dg-verdict {
2257 font-size: 12px;
2258 }
2259 .cmp-table {
2260 min-width: 820px;
2261 font-size: 14.5px;
2262 }
2263 .cmp-table th,
2264 .cmp-table td {
2265 padding: 12px;
2266 }
2267 .cmp-table thead td,
2268 .cmp-table tbody th {
2269 width: 104px;
2270 }
2271 }
2272</style>