| 1 | --- |
| 2 | import Marketing from "../layouts/Marketing.astro"; |
| 3 | import Button from "../components/marketing/Button.astro"; |
| 4 | import AlchemicalDivider from "../components/marketing/AlchemicalDivider.astro"; |
| 5 | import Yantra from "../brand/Yantra.astro"; |
| 6 | import TestLoop from "../components/compare/TestLoop.astro"; |
| 7 | import OneLine from "../components/compare/OneLine.astro"; |
| 8 | |
| 9 | const TOOLS = ["Alchemy", "SST", "Pulumi", "Terraform", "AWS CDK"] as const; |
| 10 | |
| 11 | // One row per dimension; the first cell is Alchemy. |
| 12 | const 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 ---------- |
| 73 | type Layer = { name: string; sub: string }; |
| 74 | const 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. |
| 122 | const 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. |
| 138 | type Hop = { |
| 139 | label: string; |
| 140 | lane: "m" | "c" | "b"; |
| 141 | hold?: number; |
| 142 | key?: string; |
| 143 | }; |
| 144 | const TRACE_H = 170; |
| 145 | const NET_Y_ = 85; |
| 146 | const LANE_Y = { m: 46, c: 124, b: NET_Y_ }; |
| 147 | const NET_Y = NET_Y_; |
| 148 | const STEP = 128; |
| 149 | const X0 = 70; |
| 150 | type 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`. |
| 159 | const 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 | ]; |
| 203 | const 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 | ]; |
| 236 | const SPAN = 520; |
| 237 | const 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 | }; |
| 361 | const laneName = (l: Hop["lane"]) => |
| 362 | l === "m" |
| 363 | ? "your machine" |
| 364 | : l === "c" |
| 365 | ? "cloud" |
| 366 | : "on the network boundary"; |
| 367 | const 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. |
| 372 | const ALCHEMY_SLOTS = ["src", "deploy", "fn", "db"]; |
| 373 | type Slot = { |
| 374 | x: number; |
| 375 | y: number; |
| 376 | w: number; |
| 377 | label: string; |
| 378 | shown: boolean; |
| 379 | bar: string | null; |
| 380 | }; |
| 381 | const 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> |