| 1 | --- |
| 2 | /** |
| 3 | * "One line does three jobs": for each tool, the code you write to read |
| 4 | * and write one DynamoDB table from a Lambda, the config it deploys (IAM |
| 5 | * policy + env var), and what the function's code gets at runtime. |
| 6 | * Line colors show where each piece comes from: in Alchemy, one capability |
| 7 | * line produces its policy statement, the table name, and a typed client. |
| 8 | * SST's link over-grants; Pulumi and Terraform deploy the policy and env var |
| 9 | * you write line by line, and define no client. |
| 10 | */ |
| 11 | import { highlightTS } from "../marketing/highlightTS"; |
| 12 | |
| 13 | // get / put: produced by that Alchemy capability line; both: the shared |
| 14 | // env var; link: generated by SST's link; over: access the code never uses; |
| 15 | // hand: SDK calls written by hand, unchecked; policy / env: IAM and env |
| 16 | // config you define line by line. |
| 17 | type Src = "get" | "put" | "both" | "link" | "over" | "hand" | "policy" | "env"; |
| 18 | type Lang = "ts" | "hcl" | "yaml"; |
| 19 | type Line = { text: string; src?: Src; write?: boolean; file?: boolean; lang?: Lang }; |
| 20 | |
| 21 | type ToolSet = { |
| 22 | id: "alchemy" | "sst" | "pulumi" | "terraform"; |
| 23 | code: Line[]; |
| 24 | config: Line[]; |
| 25 | runtime: Line[]; |
| 26 | }; |
| 27 | |
| 28 | // Pulumi and Terraform deploy exactly the policy you write, so every config |
| 29 | // line maps back to a line of code. |
| 30 | const HAND_CONFIG = (arn: string, env: string): Line[] => [ |
| 31 | { text: "# deployed config for api" }, |
| 32 | { text: "Role:", src: "policy" }, |
| 33 | { text: " Policy:", src: "policy" }, |
| 34 | { text: ' Version: "2012-10-17"', src: "policy" }, |
| 35 | { text: " Statement:", src: "policy" }, |
| 36 | { text: " - Effect: Allow", src: "policy" }, |
| 37 | { text: " Action: [dynamodb:GetItem, dynamodb:PutItem]", src: "policy" }, |
| 38 | { text: ` Resource: ${arn}`, src: "policy" }, |
| 39 | { text: "Environment:", src: "env" }, |
| 40 | { text: " Variables:", src: "env" }, |
| 41 | { text: ` ${env}`, src: "env" }, |
| 42 | ]; |
| 43 | |
| 44 | const NO_CLIENT: Line[] = [ |
| 45 | { text: "// runtime: no client definition" }, |
| 46 | { text: "// the handler builds its own SDK calls" }, |
| 47 | { text: "// against process.env.TABLE_NAME" }, |
| 48 | ]; |
| 49 | |
| 50 | const SETS: ToolSet[] = [ |
| 51 | { |
| 52 | id: "alchemy", |
| 53 | code: [ |
| 54 | { text: "src/api.ts", file: true }, |
| 55 | { text: "export default class Api extends Lambda.Function<Api>()(" }, |
| 56 | { text: ' "Api",' }, |
| 57 | { text: " { main: import.meta.url }," }, |
| 58 | { text: " Effect.gen(function* () {" }, |
| 59 | { text: ' const sessions = yield* DynamoDB.Table("Sessions");' }, |
| 60 | { text: " const getSession = yield* DynamoDB.GetItem(sessions);", src: "get" }, |
| 61 | { text: " const saveSession = yield* DynamoDB.PutItem(sessions);", src: "put", write: true }, |
| 62 | { text: " return {" }, |
| 63 | { text: " fetch: Effect.gen(function* () {" }, |
| 64 | { text: " const { Item } = yield* getSession({ Key });" }, |
| 65 | { text: " yield* saveSession({ Item: next });", write: true }, |
| 66 | { text: " })," }, |
| 67 | { text: " };" }, |
| 68 | { text: " })," }, |
| 69 | { text: ") {}" }, |
| 70 | ], |
| 71 | config: [ |
| 72 | { text: "# deployed config for Api" }, |
| 73 | { text: "Role:" }, |
| 74 | { text: " Policy:" }, |
| 75 | { text: ' Version: "2012-10-17"' }, |
| 76 | { text: " Statement:" }, |
| 77 | { text: " - Effect: Allow", src: "get" }, |
| 78 | { text: " Action: dynamodb:GetItem", src: "get" }, |
| 79 | { text: " Resource: arn:…:table/api-sessions-8f2c", src: "get" }, |
| 80 | { text: " - Effect: Allow", src: "put", write: true }, |
| 81 | { text: " Action: dynamodb:PutItem", src: "put", write: true }, |
| 82 | { text: " Resource: arn:…:table/api-sessions-8f2c", src: "put", write: true }, |
| 83 | { text: "Environment:" }, |
| 84 | { text: " Variables:" }, |
| 85 | { text: " Sessions_tableName: api-sessions-8f2c", src: "both" }, |
| 86 | ], |
| 87 | runtime: [ |
| 88 | { text: "// runtime: typed clients" }, |
| 89 | { text: "const getSession: (", src: "get" }, |
| 90 | { text: " request: GetItemRequest,", src: "get" }, |
| 91 | { text: ") => Effect<GetItemOutput, GetItemError>", src: "get" }, |
| 92 | { text: "const saveSession: (", src: "put", write: true }, |
| 93 | { text: " request: PutItemRequest,", src: "put", write: true }, |
| 94 | { text: ") => Effect<PutItemOutput, PutItemError>", src: "put", write: true }, |
| 95 | ], |
| 96 | }, |
| 97 | { |
| 98 | id: "sst", |
| 99 | code: [ |
| 100 | { text: "sst.config.ts", file: true }, |
| 101 | { text: 'const sessions = new sst.aws.Dynamo("Sessions", { … });' }, |
| 102 | { text: 'new sst.aws.Function("Api", {' }, |
| 103 | { text: ' handler: "src/api.handler",' }, |
| 104 | { text: " link: [sessions],", src: "link" }, |
| 105 | { text: "});" }, |
| 106 | { text: "src/api.ts", file: true }, |
| 107 | { text: 'import { Resource } from "sst";' }, |
| 108 | { text: "const client = new DynamoDBClient({});" }, |
| 109 | { text: "export const handler = async () => {" }, |
| 110 | { text: " const TableName = Resource.Sessions.name;", src: "link" }, |
| 111 | { text: " const { Item } = await client.send(", src: "hand" }, |
| 112 | { text: " new GetItemCommand({ TableName, Key }));", src: "hand" }, |
| 113 | { text: " await client.send(", src: "hand" }, |
| 114 | { text: " new PutItemCommand({ TableName, Item: next }));", src: "hand" }, |
| 115 | { text: "};" }, |
| 116 | ], |
| 117 | config: [ |
| 118 | { text: "# deployed config for Api" }, |
| 119 | { text: "Role:" }, |
| 120 | { text: " Policy:" }, |
| 121 | { text: ' Version: "2012-10-17"' }, |
| 122 | { text: " Statement:" }, |
| 123 | { text: " - Effect: Allow", src: "link" }, |
| 124 | { text: " # every DynamoDB action, DeleteTable too", src: "over" }, |
| 125 | { text: ' Action: "dynamodb:*"', src: "over" }, |
| 126 | { text: " Resource:", src: "link" }, |
| 127 | { text: " - arn:…:table/app-sessions", src: "link" }, |
| 128 | { text: " - arn:…:table/app-sessions/*", src: "link" }, |
| 129 | { text: "Environment:" }, |
| 130 | { text: " Variables:" }, |
| 131 | { text: ` SST_RESOURCE_Sessions: '{"name":"app-sessions",…}'`, src: "link" }, |
| 132 | ], |
| 133 | runtime: [ |
| 134 | { text: "// runtime" }, |
| 135 | { text: "const TableName: string; // typed by link", src: "link" }, |
| 136 | { text: "// no client definition: new DynamoDBClient()", src: "hand" }, |
| 137 | { text: "// and send() any command, allowed or not", src: "hand" }, |
| 138 | ], |
| 139 | }, |
| 140 | { |
| 141 | id: "pulumi", |
| 142 | code: [ |
| 143 | { text: "index.ts", file: true }, |
| 144 | { text: 'const sessions = new aws.dynamodb.Table("sessions", { … });' }, |
| 145 | { text: 'const role = new aws.iam.Role("api", { assumeRolePolicy: … });', src: "policy" }, |
| 146 | { text: 'new aws.iam.RolePolicy("api", {', src: "policy" }, |
| 147 | { text: " role: role.id,", src: "policy" }, |
| 148 | { text: " policy: sessions.arn.apply((arn) => JSON.stringify({", src: "policy" }, |
| 149 | { text: ' Version: "2012-10-17",', src: "policy" }, |
| 150 | { text: " Statement: [{", src: "policy" }, |
| 151 | { text: ' Effect: "Allow",', src: "policy" }, |
| 152 | { text: ' Action: ["dynamodb:GetItem", "dynamodb:PutItem"],', src: "policy" }, |
| 153 | { text: " Resource: arn,", src: "policy" }, |
| 154 | { text: " }],", src: "policy" }, |
| 155 | { text: " })),", src: "policy" }, |
| 156 | { text: "});", src: "policy" }, |
| 157 | { text: 'new aws.lambda.Function("api", {' }, |
| 158 | { text: " role: role.arn," }, |
| 159 | { text: " environment: {", src: "env" }, |
| 160 | { text: " variables: { TABLE_NAME: sessions.name },", src: "env" }, |
| 161 | { text: " },", src: "env" }, |
| 162 | { text: "});" }, |
| 163 | ], |
| 164 | config: HAND_CONFIG("arn:…:table/sessions-4d1a", "TABLE_NAME: sessions-4d1a"), |
| 165 | runtime: NO_CLIENT, |
| 166 | }, |
| 167 | { |
| 168 | id: "terraform", |
| 169 | code: [ |
| 170 | { text: "main.tf", file: true, lang: "hcl" }, |
| 171 | { text: 'resource "aws_dynamodb_table" "sessions" { … }', lang: "hcl" }, |
| 172 | { text: 'resource "aws_iam_role" "api" { assume_role_policy = … }', src: "policy", lang: "hcl" }, |
| 173 | { text: 'resource "aws_iam_role_policy" "api" {', src: "policy", lang: "hcl" }, |
| 174 | { text: " role = aws_iam_role.api.id", src: "policy", lang: "hcl" }, |
| 175 | { text: " policy = jsonencode({", src: "policy", lang: "hcl" }, |
| 176 | { text: ' Version = "2012-10-17"', src: "policy", lang: "hcl" }, |
| 177 | { text: " Statement = [{", src: "policy", lang: "hcl" }, |
| 178 | { text: ' Effect = "Allow"', src: "policy", lang: "hcl" }, |
| 179 | { text: ' Action = ["dynamodb:GetItem", "dynamodb:PutItem"]', src: "policy", lang: "hcl" }, |
| 180 | { text: " Resource = aws_dynamodb_table.sessions.arn", src: "policy", lang: "hcl" }, |
| 181 | { text: " }]", src: "policy", lang: "hcl" }, |
| 182 | { text: " })", src: "policy", lang: "hcl" }, |
| 183 | { text: "}", src: "policy", lang: "hcl" }, |
| 184 | { text: 'resource "aws_lambda_function" "api" {', lang: "hcl" }, |
| 185 | { text: " role = aws_iam_role.api.arn", lang: "hcl" }, |
| 186 | { text: " environment {", src: "env", lang: "hcl" }, |
| 187 | { text: " variables = {", src: "env", lang: "hcl" }, |
| 188 | { text: " TABLE_NAME = aws_dynamodb_table.sessions.name", src: "env", lang: "hcl" }, |
| 189 | { text: " }", src: "env", lang: "hcl" }, |
| 190 | { text: " }", src: "env", lang: "hcl" }, |
| 191 | { text: "}", lang: "hcl" }, |
| 192 | ], |
| 193 | config: HAND_CONFIG("arn:…:table/sessions", "TABLE_NAME: sessions"), |
| 194 | runtime: NO_CLIENT, |
| 195 | }, |
| 196 | ]; |
| 197 | |
| 198 | const esc = (t: string) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;"); |
| 199 | const yaml = (t: string) => { |
| 200 | if (/^\s*#/.test(t)) return `<span class="alc-tok-c">${esc(t)}</span>`; |
| 201 | const m = t.match(/^(\s*)(- )?([A-Za-z_]\w*):(\s.*|)$/); |
| 202 | if (!m) { |
| 203 | const d = t.match(/^(\s*)- (.*)$/); |
| 204 | return d |
| 205 | ? `${d[1]}<span class="ol__dash">- </span><span class="ol__str">${esc(d[2]!)}</span>` |
| 206 | : esc(t); |
| 207 | } |
| 208 | const [, indent, dash = "", key, rest] = m; |
| 209 | const value = rest!.trim(); |
| 210 | return ( |
| 211 | indent + |
| 212 | (dash ? `<span class="ol__dash">${dash}</span>` : "") + |
| 213 | `<span class="ol__key">${esc(key!)}</span>:` + |
| 214 | (value ? ` <span class="ol__str">${esc(value)}</span>` : "") |
| 215 | ); |
| 216 | }; |
| 217 | const hcl = (t: string) => |
| 218 | esc(t) |
| 219 | .replace(/("[^"]*")/g, '<span class="ol__str">$1</span>') |
| 220 | .replace(/^(\s*)(resource)\b/, '$1<span class="alc-tok-k">$2</span>') |
| 221 | .replace(/^(\s*)([A-Za-z_]\w*)(\s*=)/, '$1<span class="ol__key">$2</span>$3'); |
| 222 | const render = (l: Line, lang: Lang) => |
| 223 | l.file ? esc(l.text) : (l.lang ?? lang) === "hcl" ? hcl(l.text) : lang === "yaml" ? yaml(l.text) : highlightTS(l.text); |
| 224 | |
| 225 | // Classes for a line, plus run edges so each colored block gets a little |
| 226 | // breathing room above and below. |
| 227 | const classes = (lines: Line[], i: number) => { |
| 228 | const l = lines[i]!; |
| 229 | return [ |
| 230 | "ol__line", |
| 231 | l.src && `is-${l.src}`, |
| 232 | l.write && "is-write", |
| 233 | l.file && "is-file", |
| 234 | l.src && lines[i - 1]?.src !== l.src && "is-run-start", |
| 235 | l.src && lines[i + 1]?.src !== l.src && "is-run-end", |
| 236 | ]; |
| 237 | }; |
| 238 | const label = { alchemy: "Alchemy", sst: "SST", pulumi: "Pulumi", terraform: "Terraform" }; |
| 239 | --- |
| 240 | |
| 241 | <figure class="ol" data-state="read" data-tool="alchemy"> |
| 242 | <fieldset class="ol__modes"> |
| 243 | <legend class="ol__sr">Show how each tool wires the function</legend> |
| 244 | <label> |
| 245 | <input type="radio" name="ol-state" value="alchemy-read" checked /> |
| 246 | <span data-copy>Alchemy · read</span> |
| 247 | </label> |
| 248 | <label> |
| 249 | <input type="radio" name="ol-state" value="alchemy-write" /> |
| 250 | <span data-copy>Alchemy · read + write</span> |
| 251 | </label> |
| 252 | <label> |
| 253 | <input type="radio" name="ol-state" value="sst" /> |
| 254 | <span data-copy>SST</span> |
| 255 | </label> |
| 256 | <label> |
| 257 | <input type="radio" name="ol-state" value="pulumi" /> |
| 258 | <span data-copy>Pulumi</span> |
| 259 | </label> |
| 260 | <label> |
| 261 | <input type="radio" name="ol-state" value="terraform" /> |
| 262 | <span data-copy>Terraform</span> |
| 263 | </label> |
| 264 | </fieldset> |
| 265 | |
| 266 | <div class="ol__sets"> |
| 267 | { |
| 268 | SETS.map((set) => ( |
| 269 | <div class:list={["ol__set", set.id === "alchemy" && "is-active"]} data-set={set.id}> |
| 270 | <div class="ol__stage"> |
| 271 | <div class="ol__card ol__code"> |
| 272 | <pre class="ol__pre" tabindex="0" aria-label={`${label[set.id]}: the code you write`}><code>{ |
| 273 | set.code.map((l, i) => ( |
| 274 | <span class:list={classes(set.code, i)} set:html={render(l, "ts")} /> |
| 275 | )) |
| 276 | }</code></pre> |
| 277 | </div> |
| 278 | <div class="ol__outs"> |
| 279 | <div class="ol__card"> |
| 280 | <pre class="ol__pre" tabindex="0" aria-label={`${label[set.id]}: deployed IAM policy and environment variables`}><code>{ |
| 281 | set.config.map((l, i) => ( |
| 282 | <span class:list={classes(set.config, i)} set:html={render(l, "yaml")} /> |
| 283 | )) |
| 284 | }</code></pre> |
| 285 | </div> |
| 286 | <div class="ol__card"> |
| 287 | <pre class="ol__pre" tabindex="0" aria-label={`${label[set.id]}: what the function's code gets at runtime`}><code>{ |
| 288 | set.runtime.map((l, i) => ( |
| 289 | <span class:list={classes(set.runtime, i)} set:html={render(l, "ts")} /> |
| 290 | )) |
| 291 | }</code></pre> |
| 292 | </div> |
| 293 | </div> |
| 294 | </div> |
| 295 | |
| 296 | {set.id === "alchemy" && ( |
| 297 | <> |
| 298 | <ul class="ol__legend" aria-label="Legend"> |
| 299 | <li class="is-get" data-copy> |
| 300 | <code>GetItem</code> grants read and returns <code>getSession</code> |
| 301 | </li> |
| 302 | <li class="is-put is-write" data-copy> |
| 303 | <code>PutItem</code> grants write and returns <code>saveSession</code> |
| 304 | </li> |
| 305 | </ul> |
| 306 | <p class="ol__note" data-when="read" data-copy> |
| 307 | One line asks for read access. It grants exactly |
| 308 | <code>dynamodb:GetItem</code> on this table, injects the |
| 309 | table's name, and returns a typed client. |
| 310 | </p> |
| 311 | <p class="ol__note" data-when="write" data-copy> |
| 312 | Add <code>PutItem</code> and its statement, its env var, and |
| 313 | its client all follow. Remove a line and all three go with it. |
| 314 | </p> |
| 315 | </> |
| 316 | )} |
| 317 | {set.id === "sst" && ( |
| 318 | <> |
| 319 | <ul class="ol__legend" aria-label="Legend"> |
| 320 | <li class="is-link" data-copy>generated by <code>link</code></li> |
| 321 | <li class="is-over" data-copy>more access than the code uses</li> |
| 322 | <li class="is-hand" data-copy>written by hand, unchecked</li> |
| 323 | </ul> |
| 324 | <p class="ol__note" data-copy> |
| 325 | <code>link</code> writes the policy for you, but it grants <code>dynamodb:*</code> on the table, <code>DeleteTable</code> included, while the handler only calls <code>GetItem</code> and <code>PutItem</code>. There's no client definition, so the SDK calls are yours to keep in sync. |
| 326 | </p> |
| 327 | </> |
| 328 | )} |
| 329 | {(set.id === "pulumi" || set.id === "terraform") && ( |
| 330 | <> |
| 331 | <ul class="ol__legend" aria-label="Legend"> |
| 332 | <li class="is-policy" data-copy>IAM policy: every line is yours to write</li> |
| 333 | <li class="is-env" data-copy>env var: yours to wire</li> |
| 334 | </ul> |
| 335 | <p class="ol__note" data-copy> |
| 336 | {set.id === "pulumi" ? "Pulumi" : "Terraform"} deploys exactly |
| 337 | the policy you define: each statement, action, and ARN in the |
| 338 | config traces back to a line you wrote. There's no client |
| 339 | definition, so nothing checks your handler's SDK calls against |
| 340 | either. |
| 341 | </p> |
| 342 | </> |
| 343 | )} |
| 344 | </div> |
| 345 | )) |
| 346 | } |
| 347 | </div> |
| 348 | <p class="ol__sr" aria-live="polite" data-live></p> |
| 349 | </figure> |
| 350 | |
| 351 | <script> |
| 352 | // Switches between tools (crossfade, with the height easing between |
| 353 | // them) and between Alchemy's read and read + write (lines slide in and |
| 354 | // out, handled in CSS). |
| 355 | const SWITCH_MS = 420; |
| 356 | for (const fig of document.querySelectorAll<HTMLElement>(".ol")) { |
| 357 | const wrap = fig.querySelector<HTMLElement>(".ol__sets")!; |
| 358 | const sets = [...fig.querySelectorAll<HTMLElement>(".ol__set")]; |
| 359 | const live = fig.querySelector<HTMLElement>("[data-live]")!; |
| 360 | for (const s of sets) if (!s.classList.contains("is-active")) s.inert = true; |
| 361 | let timer = 0; |
| 362 | |
| 363 | const announce = () => { |
| 364 | const active = sets.find((s) => s.classList.contains("is-active"))!; |
| 365 | const note = [...active.querySelectorAll<HTMLElement>(".ol__note")].find( |
| 366 | (n) => getComputedStyle(n).display !== "none", |
| 367 | ); |
| 368 | live.textContent = note?.textContent?.replace(/\s+/g, " ").trim() ?? ""; |
| 369 | }; |
| 370 | |
| 371 | fig.addEventListener("change", (e) => { |
| 372 | const value = (e.target as HTMLInputElement).value; |
| 373 | const [tool, mode] = value.split("-") as [string, string | undefined]; |
| 374 | if (mode) fig.dataset.state = mode; |
| 375 | if (tool === fig.dataset.tool) { |
| 376 | announce(); |
| 377 | return; |
| 378 | } |
| 379 | fig.dataset.tool = tool; |
| 380 | clearTimeout(timer); |
| 381 | wrap.style.height = `${wrap.offsetHeight}px`; |
| 382 | for (const s of sets) { |
| 383 | const on = s.dataset.set === tool; |
| 384 | s.classList.toggle("is-active", on); |
| 385 | s.inert = !on; |
| 386 | } |
| 387 | const next = sets.find((s) => s.dataset.set === tool)!; |
| 388 | requestAnimationFrame(() => { |
| 389 | wrap.classList.add("is-switching"); |
| 390 | wrap.style.height = `${next.offsetHeight}px`; |
| 391 | timer = window.setTimeout(() => { |
| 392 | wrap.classList.remove("is-switching"); |
| 393 | wrap.style.height = ""; |
| 394 | }, SWITCH_MS); |
| 395 | }); |
| 396 | announce(); |
| 397 | }); |
| 398 | } |
| 399 | </script> |
| 400 | |
| 401 | <style> |
| 402 | .ol { |
| 403 | /* A quarter of a code line. */ |
| 404 | --run-pad: calc(1.65em / 4); |
| 405 | --get: var(--alc-accent); |
| 406 | --put: #6da4b4; |
| 407 | --link: var(--alc-accent); |
| 408 | --hand: var(--alc-warn); |
| 409 | --over: var(--alc-danger); |
| 410 | --policy: var(--alc-warn); |
| 411 | --env: #a592d6; |
| 412 | margin: 0; |
| 413 | } |
| 414 | :global([data-theme="light"]) .ol { |
| 415 | --put: #3f7a8c; |
| 416 | --env: #6c55a8; |
| 417 | } |
| 418 | .ol__sr { |
| 419 | position: absolute; |
| 420 | width: 1px; |
| 421 | height: 1px; |
| 422 | overflow: hidden; |
| 423 | clip-path: inset(50%); |
| 424 | white-space: nowrap; |
| 425 | } |
| 426 | |
| 427 | /* ---------- picker ---------- */ |
| 428 | .ol__modes { |
| 429 | display: flex; |
| 430 | flex-wrap: wrap; |
| 431 | gap: 8px; |
| 432 | margin: 0 0 18px; |
| 433 | padding: 0; |
| 434 | border: 0; |
| 435 | } |
| 436 | .ol__modes label { |
| 437 | position: relative; |
| 438 | cursor: pointer; |
| 439 | } |
| 440 | .ol__modes input { |
| 441 | position: absolute; |
| 442 | inset: 0; |
| 443 | margin: 0; |
| 444 | opacity: 0; |
| 445 | cursor: pointer; |
| 446 | } |
| 447 | .ol__modes span { |
| 448 | display: inline-block; |
| 449 | padding: 6px 14px; |
| 450 | border: 1px dashed var(--alc-hairline-3); |
| 451 | border-radius: 999px; |
| 452 | font-size: 13px; |
| 453 | color: var(--alc-fg-2); |
| 454 | transition: |
| 455 | border-color 200ms ease, |
| 456 | background 200ms ease, |
| 457 | color 200ms ease; |
| 458 | } |
| 459 | .ol__modes label:hover span { |
| 460 | color: var(--alc-fg-1); |
| 461 | } |
| 462 | .ol__modes input:checked + span { |
| 463 | border-style: solid; |
| 464 | border-color: var(--alc-accent); |
| 465 | background: color-mix(in srgb, var(--alc-accent) 12%, transparent); |
| 466 | color: var(--alc-fg-1); |
| 467 | } |
| 468 | .ol__modes input:focus-visible + span { |
| 469 | outline: 2px solid var(--alc-accent); |
| 470 | outline-offset: 2px; |
| 471 | } |
| 472 | |
| 473 | /* ---------- tool sets: crossfade with the height easing ---------- */ |
| 474 | .ol__sets { |
| 475 | position: relative; |
| 476 | } |
| 477 | .ol__sets.is-switching { |
| 478 | transition: height 420ms cubic-bezier(0.2, 0, 0, 1); |
| 479 | } |
| 480 | .ol__set { |
| 481 | position: absolute; |
| 482 | top: 0; |
| 483 | left: 0; |
| 484 | right: 0; |
| 485 | opacity: 0; |
| 486 | visibility: hidden; |
| 487 | transition: |
| 488 | opacity 220ms ease, |
| 489 | visibility 0s linear 220ms; |
| 490 | } |
| 491 | .ol__set.is-active { |
| 492 | position: relative; |
| 493 | opacity: 1; |
| 494 | visibility: visible; |
| 495 | transition: opacity 320ms ease 120ms; |
| 496 | } |
| 497 | |
| 498 | /* ---------- layout ---------- */ |
| 499 | .ol__stage { |
| 500 | display: grid; |
| 501 | grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); |
| 502 | column-gap: 24px; |
| 503 | align-items: center; |
| 504 | } |
| 505 | .ol__outs { |
| 506 | display: grid; |
| 507 | gap: 14px; |
| 508 | } |
| 509 | |
| 510 | /* ---------- cards ---------- */ |
| 511 | .ol__card { |
| 512 | min-width: 0; |
| 513 | border: 1px solid rgba(232, 220, 192, 0.1); |
| 514 | border-radius: 12px; |
| 515 | background: var(--alc-bg-code); |
| 516 | overflow: hidden; |
| 517 | } |
| 518 | .ol__pre { |
| 519 | margin: 0; |
| 520 | padding: 12px 0; |
| 521 | overflow-x: auto; |
| 522 | font-family: var(--alc-font-mono); |
| 523 | font-size: 12px; |
| 524 | line-height: 1.65; |
| 525 | color: var(--alc-code-var); |
| 526 | } |
| 527 | .ol__pre:focus-visible { |
| 528 | outline: 2px solid var(--alc-accent); |
| 529 | outline-offset: -2px; |
| 530 | } |
| 531 | .ol__line { |
| 532 | display: block; |
| 533 | max-height: 1.65em; |
| 534 | /* Clip only vertically (for the slide); long lines still scroll the |
| 535 | card sideways. */ |
| 536 | overflow-x: visible; |
| 537 | overflow-y: clip; |
| 538 | padding: 0 16px; |
| 539 | white-space: pre; |
| 540 | transition: |
| 541 | max-height 380ms cubic-bezier(0.2, 0, 0, 1), |
| 542 | padding 380ms cubic-bezier(0.2, 0, 0, 1), |
| 543 | opacity 280ms ease, |
| 544 | background 300ms ease, |
| 545 | box-shadow 300ms ease; |
| 546 | } |
| 547 | /* File names inside the code card. */ |
| 548 | .ol__line.is-file { |
| 549 | color: var(--alc-code-comment); |
| 550 | font-size: 11px; |
| 551 | } |
| 552 | .ol__line.is-file:not(:first-child) { |
| 553 | margin-top: 10px; |
| 554 | padding-top: 8px; |
| 555 | border-top: 1px solid rgba(232, 220, 192, 0.08); |
| 556 | max-height: none; |
| 557 | } |
| 558 | /* Colored runs in the outputs get a little room above and below. */ |
| 559 | .ol__outs .ol__line.is-run-start { |
| 560 | padding-top: var(--run-pad); |
| 561 | } |
| 562 | .ol__outs .ol__line.is-run-end { |
| 563 | padding-bottom: var(--run-pad); |
| 564 | } |
| 565 | /* Lines that only exist with PutItem slide closed in the read state. */ |
| 566 | .ol[data-state="read"] .ol__line.is-write { |
| 567 | max-height: 0; |
| 568 | padding-top: 0; |
| 569 | padding-bottom: 0; |
| 570 | opacity: 0; |
| 571 | } |
| 572 | .ol__line.is-get { |
| 573 | background: color-mix(in srgb, var(--get) 14%, transparent); |
| 574 | box-shadow: inset 3px 0 0 var(--get); |
| 575 | } |
| 576 | .ol__line.is-put { |
| 577 | background: color-mix(in srgb, var(--put) 16%, transparent); |
| 578 | box-shadow: inset 3px 0 0 var(--put); |
| 579 | } |
| 580 | .ol__line.is-link { |
| 581 | background: color-mix(in srgb, var(--link) 14%, transparent); |
| 582 | box-shadow: inset 3px 0 0 var(--link); |
| 583 | } |
| 584 | .ol__line.is-hand { |
| 585 | background: color-mix(in srgb, var(--hand) 12%, transparent); |
| 586 | box-shadow: inset 3px 0 0 var(--hand); |
| 587 | } |
| 588 | .ol__line.is-over { |
| 589 | background: color-mix(in srgb, var(--over) 16%, transparent); |
| 590 | box-shadow: inset 3px 0 0 var(--over); |
| 591 | } |
| 592 | .ol__line.is-policy { |
| 593 | background: color-mix(in srgb, var(--policy) 12%, transparent); |
| 594 | box-shadow: inset 3px 0 0 var(--policy); |
| 595 | } |
| 596 | .ol__line.is-env { |
| 597 | background: color-mix(in srgb, var(--env) 16%, transparent); |
| 598 | box-shadow: inset 3px 0 0 var(--env); |
| 599 | } |
| 600 | /* The env var: both capabilities inject it once PutItem is added. */ |
| 601 | .ol__line.is-both { |
| 602 | background: linear-gradient( |
| 603 | 90deg, |
| 604 | color-mix(in srgb, var(--get) 14%, transparent), |
| 605 | color-mix(in srgb, var(--put) 16%, transparent) |
| 606 | ); |
| 607 | box-shadow: |
| 608 | inset 3px 0 0 var(--get), |
| 609 | inset 6px 0 0 var(--put); |
| 610 | } |
| 611 | .ol[data-state="read"] .ol__line.is-both { |
| 612 | background: color-mix(in srgb, var(--get) 14%, transparent); |
| 613 | box-shadow: inset 3px 0 0 var(--get); |
| 614 | } |
| 615 | .ol__key { |
| 616 | color: var(--alc-code-type); |
| 617 | } |
| 618 | .ol__str { |
| 619 | color: var(--alc-code-string); |
| 620 | } |
| 621 | .ol__dash { |
| 622 | color: var(--alc-code-comment); |
| 623 | } |
| 624 | |
| 625 | /* ---------- legend + note ---------- */ |
| 626 | .ol__legend { |
| 627 | display: flex; |
| 628 | flex-wrap: wrap; |
| 629 | gap: 8px 22px; |
| 630 | margin: 18px 0 0; |
| 631 | padding: 0; |
| 632 | list-style: none; |
| 633 | font-size: 13px; |
| 634 | color: var(--alc-fg-3); |
| 635 | } |
| 636 | .ol__legend li { |
| 637 | display: inline-flex; |
| 638 | align-items: center; |
| 639 | gap: 8px; |
| 640 | transition: opacity 280ms ease; |
| 641 | } |
| 642 | .ol[data-state="read"] .ol__legend li.is-write { |
| 643 | opacity: 0; |
| 644 | } |
| 645 | .ol__legend li::before { |
| 646 | content: ""; |
| 647 | flex: none; |
| 648 | width: 12px; |
| 649 | height: 12px; |
| 650 | border-radius: 3px; |
| 651 | } |
| 652 | .ol__legend li.is-get::before { |
| 653 | background: var(--get); |
| 654 | } |
| 655 | .ol__legend li.is-put::before { |
| 656 | background: var(--put); |
| 657 | } |
| 658 | .ol__legend li.is-link::before { |
| 659 | background: var(--link); |
| 660 | } |
| 661 | .ol__legend li.is-hand::before { |
| 662 | background: var(--hand); |
| 663 | } |
| 664 | .ol__legend li.is-over::before { |
| 665 | background: var(--over); |
| 666 | } |
| 667 | .ol__legend li.is-policy::before { |
| 668 | background: var(--policy); |
| 669 | } |
| 670 | .ol__legend li.is-env::before { |
| 671 | background: var(--env); |
| 672 | } |
| 673 | .ol__legend code, |
| 674 | .ol__note code { |
| 675 | font-family: var(--alc-font-mono); |
| 676 | font-size: 0.92em; |
| 677 | color: var(--alc-fg-1); |
| 678 | } |
| 679 | .ol__note { |
| 680 | max-width: 680px; |
| 681 | margin: 10px 0 0; |
| 682 | font-size: 15px; |
| 683 | line-height: 1.6; |
| 684 | color: var(--alc-fg-2); |
| 685 | } |
| 686 | .ol[data-state="read"] .ol__note[data-when="write"], |
| 687 | .ol[data-state="write"] .ol__note[data-when="read"] { |
| 688 | display: none; |
| 689 | } |
| 690 | |
| 691 | @media (max-width: 1000px) { |
| 692 | .ol__stage { |
| 693 | grid-template-columns: minmax(0, 1fr); |
| 694 | row-gap: 14px; |
| 695 | } |
| 696 | } |
| 697 | @media (max-width: 640px) { |
| 698 | .ol__pre { |
| 699 | font-size: 11px; |
| 700 | } |
| 701 | } |
| 702 | </style> |