| 1 | --- |
| 2 | /** |
| 3 | * The compare page's testing loop: one deploy → test → destroy → edit loop |
| 4 | * whose nodes and arcs restyle per tool to show exactly where each one's |
| 5 | * loop breaks. Alchemy closes it; SST leaves deploy/destroy to the CLI; |
| 6 | * Pulumi leaves the test harness to you; Terraform can't test the app. |
| 7 | */ |
| 8 | import { highlightTS } from "../marketing/highlightTS"; |
| 9 | |
| 10 | type State = "ok" | "diy" | "broken"; |
| 11 | type Tool = { |
| 12 | id: string; |
| 13 | label: string; |
| 14 | // deploy, test, destroy, edit |
| 15 | nodes: [State, State, State, State]; |
| 16 | // arc i runs from node i to node i + 1 |
| 17 | arcs: [State, State, State, State]; |
| 18 | times: [string, string, string, string]; |
| 19 | total: string; |
| 20 | caption: string; |
| 21 | file: string; |
| 22 | code: string; |
| 23 | lang?: "hcl"; |
| 24 | }; |
| 25 | |
| 26 | const TOOLS: Tool[] = [ |
| 27 | { |
| 28 | id: "alchemy", |
| 29 | label: "Alchemy", |
| 30 | nodes: ["ok", "ok", "ok", "ok"], |
| 31 | arcs: ["ok", "ok", "ok", "ok"], |
| 32 | times: ["~12s", "<1s", "~4s", ""], |
| 33 | total: "~17s", |
| 34 | caption: "per loop", |
| 35 | file: "test/api.test.ts", |
| 36 | code: `const stack = beforeAll(deploy(Stack)); |
| 37 | afterAll(destroy(Stack)); |
| 38 | |
| 39 | test("stores a session", Effect.gen(function* () { |
| 40 | const { url } = yield* stack; |
| 41 | const res = yield* HttpClient.post(\`\${url}/session\`); |
| 42 | expect(res.status).toBe(200); |
| 43 | }));`, |
| 44 | }, |
| 45 | { |
| 46 | id: "sst", |
| 47 | label: "SST", |
| 48 | nodes: ["diy", "ok", "diy", "ok"], |
| 49 | arcs: ["diy", "diy", "diy", "diy"], |
| 50 | times: ["by hand", "", "by hand", ""], |
| 51 | total: "manual", |
| 52 | caption: "outside the test", |
| 53 | file: "terminal", |
| 54 | code: `# 1. deploy the stage yourself |
| 55 | sst deploy --stage test |
| 56 | # 2. run tests with linked resources |
| 57 | sst shell --stage test -- vitest run |
| 58 | # 3. tear it down yourself, even on failure |
| 59 | sst remove --stage test`, |
| 60 | }, |
| 61 | { |
| 62 | id: "pulumi", |
| 63 | label: "Pulumi", |
| 64 | nodes: ["ok", "diy", "ok", "ok"], |
| 65 | arcs: ["diy", "diy", "ok", "ok"], |
| 66 | times: ["", "", "", ""], |
| 67 | total: "DIY", |
| 68 | caption: "build the harness", |
| 69 | file: "test/harness.ts", |
| 70 | code: `const stack = await LocalWorkspace.createOrSelectStack({ |
| 71 | stackName: "test", projectName: "api", program, |
| 72 | }); |
| 73 | const { outputs } = await stack.up(); |
| 74 | // pass outputs to your tests yourself… |
| 75 | await runTests(outputs.url.value); |
| 76 | // …and clean up yourself, even when they fail |
| 77 | await stack.destroy();`, |
| 78 | }, |
| 79 | { |
| 80 | id: "terraform", |
| 81 | label: "Terraform", |
| 82 | nodes: ["ok", "broken", "ok", "ok"], |
| 83 | arcs: ["ok", "broken", "ok", "ok"], |
| 84 | times: ["", "", "", ""], |
| 85 | total: "infra only", |
| 86 | caption: "app never tested", |
| 87 | file: "tests/api.tftest.hcl", |
| 88 | lang: "hcl", |
| 89 | code: `run "creates_table" { |
| 90 | command = apply |
| 91 | assert { |
| 92 | condition = ( |
| 93 | aws_dynamodb_table.sessions.billing_mode == "PAY_PER_REQUEST" |
| 94 | ) |
| 95 | error_message = "wrong billing mode" |
| 96 | } |
| 97 | } |
| 98 | # asserts on resource attributes only; |
| 99 | # nothing here calls your running app`, |
| 100 | }, |
| 101 | ]; |
| 102 | |
| 103 | const c = 150; |
| 104 | const r = 108; |
| 105 | const pt = (deg: number) => { |
| 106 | const t = (deg * Math.PI) / 180; |
| 107 | return [+(c + r * Math.cos(t)).toFixed(1), +(c + r * Math.sin(t)).toFixed(1)] as const; |
| 108 | }; |
| 109 | const DEGS = [-90, 0, 90, 180]; |
| 110 | const STEPS = ["deploy", "test", "destroy", "edit"]; |
| 111 | const NODES = DEGS.map(pt); |
| 112 | const ARCS = DEGS.map((d) => { |
| 113 | const [x0, y0] = pt(d + 24); |
| 114 | const [x1, y1] = pt(d + 66); |
| 115 | return `M ${x0} ${y0} A ${r} ${r} 0 0 1 ${x1} ${y1}`; |
| 116 | }); |
| 117 | |
| 118 | const esc = (t: string) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;"); |
| 119 | const hcl = (t: string) => |
| 120 | t |
| 121 | .split("\n") |
| 122 | .map((line) => |
| 123 | /^\s*#/.test(line) |
| 124 | ? `<span class="alc-tok-c">${esc(line)}</span>` |
| 125 | : esc(line) |
| 126 | .replace(/("[^"]*")/g, '<span class="alc-tok-s">$1</span>') |
| 127 | .replace(/^(\s*)(run|assert)\b/, '$1<span class="alc-tok-k">$2</span>'), |
| 128 | ) |
| 129 | .join("\n"); |
| 130 | const shell = (t: string) => |
| 131 | t |
| 132 | .split("\n") |
| 133 | .map((line) => |
| 134 | /^\s*#/.test(line) ? `<span class="alc-tok-c">${esc(line)}</span>` : esc(line), |
| 135 | ) |
| 136 | .join("\n"); |
| 137 | const render = (t: Tool) => |
| 138 | t.lang === "hcl" ? hcl(t.code) : t.file === "terminal" ? shell(t.code) : highlightTS(t.code); |
| 139 | |
| 140 | const verb = (s: State) => |
| 141 | s === "ok" ? "works" : s === "diy" ? "is yours to build" : "can't test the app"; |
| 142 | const aria = (t: Tool) => |
| 143 | `${t.label}: ${STEPS.map((s, i) => `${s} ${verb(t.nodes[i]!)}`).join(", ")}.`; |
| 144 | --- |
| 145 | |
| 146 | <div class="tl" data-tool="alchemy"> |
| 147 | <fieldset class="tl__modes"> |
| 148 | <legend class="tl__sr">Compare the testing loop</legend> |
| 149 | { |
| 150 | TOOLS.map((t, i) => ( |
| 151 | <label> |
| 152 | <input type="radio" name="tl-tool" value={t.id} checked={i === 0} /> |
| 153 | <span data-copy>{t.label}</span> |
| 154 | </label> |
| 155 | )) |
| 156 | } |
| 157 | </fieldset> |
| 158 | |
| 159 | <div class="tl__stage"> |
| 160 | <figure class="tl__loop"> |
| 161 | <svg |
| 162 | viewBox="0 0 300 300" |
| 163 | role="img" |
| 164 | aria-label={aria(TOOLS[0]!)} |
| 165 | data-aria={JSON.stringify(Object.fromEntries(TOOLS.map((t) => [t.id, aria(t)])))} |
| 166 | > |
| 167 | <defs> |
| 168 | {["ok", "diy", "broken"].map((s) => ( |
| 169 | <marker |
| 170 | id={`tl-arrow-${s}`} |
| 171 | class={`tl__arrow is-${s}`} |
| 172 | viewBox="0 0 10 10" |
| 173 | refX="7" |
| 174 | refY="5" |
| 175 | markerWidth="7" |
| 176 | markerHeight="7" |
| 177 | orient="auto-start-reverse" |
| 178 | > |
| 179 | <path d="M0 1 L9 5 L0 9 z" /> |
| 180 | </marker> |
| 181 | ))} |
| 182 | </defs> |
| 183 | <circle class="tl__track" cx={c} cy={c} r={r} /> |
| 184 | { |
| 185 | ARCS.map((d, i) => ( |
| 186 | <g class="tl__arc" data-i={i}> |
| 187 | {["ok", "diy", "broken"].map((s) => ( |
| 188 | <path class={`is-${s}`} d={d} marker-end={s === "broken" ? undefined : `url(#tl-arrow-${s})`} /> |
| 189 | ))} |
| 190 | </g> |
| 191 | )) |
| 192 | } |
| 193 | { |
| 194 | NODES.map(([x, y], i) => ( |
| 195 | <g class="tl__node" data-i={i} transform={`translate(${x} ${y})`}> |
| 196 | <circle r="30" /> |
| 197 | <text y="-2" text-anchor="middle" class="tl__label">{STEPS[i]}</text> |
| 198 | {TOOLS.map((t) => ( |
| 199 | <text y="13" text-anchor="middle" class="tl__time" data-for={t.id}> |
| 200 | {t.times[i]} |
| 201 | </text> |
| 202 | ))} |
| 203 | </g> |
| 204 | )) |
| 205 | } |
| 206 | { |
| 207 | TOOLS.map((t) => ( |
| 208 | <g class="tl__center" data-for={t.id}> |
| 209 | <text x={c} y={c - 4} text-anchor="middle" class="tl__total">{t.total}</text> |
| 210 | <text x={c} y={c + 16} text-anchor="middle" class="tl__caption">{t.caption}</text> |
| 211 | </g> |
| 212 | )) |
| 213 | } |
| 214 | </svg> |
| 215 | </figure> |
| 216 | |
| 217 | <div class="tl__side"> |
| 218 | { |
| 219 | TOOLS.map((t) => ( |
| 220 | <div class="tl__panel" data-for={t.id}> |
| 221 | <div class="alc-code-block alc-code-block--compact"> |
| 222 | <div class="alc-code-block__header"> |
| 223 | <span class="alc-code-block__filename">{t.file}</span> |
| 224 | </div> |
| 225 | <pre class="alc-code-block__pre" tabindex="0" aria-label={`${t.label}: ${t.file}`} set:html={render(t)} /> |
| 226 | </div> |
| 227 | </div> |
| 228 | )) |
| 229 | } |
| 230 | </div> |
| 231 | </div> |
| 232 | |
| 233 | <div class="tl__notes" aria-live="polite"> |
| 234 | <div class="tl__note" data-for="alchemy"> |
| 235 | <h3 data-copy>One test file closes the loop</h3> |
| 236 | <p data-copy> |
| 237 | <code>beforeAll(deploy(Stack))</code> stands up real infrastructure, |
| 238 | the test calls it, and <code>afterAll(destroy(Stack))</code> tears it |
| 239 | down, even when a test fails. |
| 240 | </p> |
| 241 | </div> |
| 242 | <div class="tl__note" data-for="sst"> |
| 243 | <h3 data-copy>Tests can't deploy &amp; destroy</h3> |
| 244 | <p data-copy> |
| 245 | <code>sst shell</code> can give tests access to resources via link, |
| 246 | but deploying and tearing down the stack still need to be done outside |
| 247 | of the test runner. |
| 248 | </p> |
| 249 | </div> |
| 250 | <div class="tl__note" data-for="pulumi"> |
| 251 | <h3 data-copy>Make your own test runner</h3> |
| 252 | <p data-copy> |
| 253 | Pulumi exposes and automation api that can deploy and destroy, but |
| 254 | passing outputs to tests and cleaning up properly after failures means |
| 255 | you need to write your own testing harness. |
| 256 | </p> |
| 257 | </div> |
| 258 | <div class="tl__note" data-for="terraform"> |
| 259 | <h3 data-copy>Tests must be entirely decoupled</h3> |
| 260 | <p data-copy> |
| 261 | Terraform test can assert what resources are deployed, but it doesn't |
| 262 | verify whats running in those resources is correct |
| 263 | </p> |
| 264 | </div> |
| 265 | </div> |
| 266 | |
| 267 | <ul class="tl__legend" aria-label="Legend"> |
| 268 | <li><span class="is-ok" aria-hidden="true"></span>part of the loop</li> |
| 269 | <li><span class="is-diy" aria-hidden="true"></span>possible, you build it</li> |
| 270 | <li><span class="is-broken" aria-hidden="true"></span>can't do what the loop needs</li> |
| 271 | </ul> |
| 272 | </div> |
| 273 | |
| 274 | <script> |
| 275 | // The loop is one SVG: switching tools restyles its nodes and arcs (CSS |
| 276 | // transitions on data-* state) and swaps the side panel and note. |
| 277 | type State = "ok" | "diy" | "broken"; |
| 278 | const STATES: Record<string, { nodes: State[]; arcs: State[] }> = { |
| 279 | alchemy: { nodes: ["ok", "ok", "ok", "ok"], arcs: ["ok", "ok", "ok", "ok"] }, |
| 280 | sst: { nodes: ["diy", "ok", "diy", "ok"], arcs: ["diy", "diy", "diy", "diy"] }, |
| 281 | pulumi: { nodes: ["ok", "diy", "ok", "ok"], arcs: ["diy", "diy", "ok", "ok"] }, |
| 282 | terraform: { nodes: ["ok", "broken", "ok", "ok"], arcs: ["ok", "broken", "ok", "ok"] }, |
| 283 | }; |
| 284 | for (const root of document.querySelectorAll<HTMLElement>(".tl")) { |
| 285 | const svg = root.querySelector("svg")!; |
| 286 | const aria = JSON.parse(svg.dataset.aria!) as Record<string, string>; |
| 287 | const apply = (tool: string) => { |
| 288 | const s = STATES[tool]!; |
| 289 | root.dataset.tool = tool; |
| 290 | root.querySelectorAll<SVGGElement>(".tl__node").forEach((n, i) => { |
| 291 | n.dataset.state = s.nodes[i]; |
| 292 | n.toggleAttribute("data-timed", !!n.querySelector(`[data-for="${tool}"]`)?.textContent?.trim()); |
| 293 | }); |
| 294 | root.querySelectorAll<SVGGElement>(".tl__arc").forEach((a, i) => (a.dataset.state = s.arcs[i])); |
| 295 | svg.setAttribute("aria-label", aria[tool]!); |
| 296 | }; |
| 297 | root.addEventListener("change", (e) => apply((e.target as HTMLInputElement).value)); |
| 298 | apply("alchemy"); |
| 299 | } |
| 300 | </script> |
| 301 | |
| 302 | <style> |
| 303 | .tl { |
| 304 | --ok: var(--alc-accent); |
| 305 | --diy: var(--alc-warn); |
| 306 | --broken: var(--alc-danger); |
| 307 | } |
| 308 | .tl__sr { |
| 309 | position: absolute; |
| 310 | width: 1px; |
| 311 | height: 1px; |
| 312 | overflow: hidden; |
| 313 | clip-path: inset(50%); |
| 314 | white-space: nowrap; |
| 315 | } |
| 316 | |
| 317 | /* ---------- picker ---------- */ |
| 318 | .tl__modes { |
| 319 | display: flex; |
| 320 | flex-wrap: wrap; |
| 321 | gap: 8px; |
| 322 | margin: 0 0 12px; |
| 323 | padding: 0; |
| 324 | border: 0; |
| 325 | } |
| 326 | .tl__modes label { |
| 327 | position: relative; |
| 328 | cursor: pointer; |
| 329 | } |
| 330 | .tl__modes input { |
| 331 | position: absolute; |
| 332 | inset: 0; |
| 333 | margin: 0; |
| 334 | opacity: 0; |
| 335 | cursor: pointer; |
| 336 | } |
| 337 | .tl__modes span { |
| 338 | display: inline-block; |
| 339 | padding: 6px 14px; |
| 340 | border: 1px dashed var(--alc-hairline-3); |
| 341 | border-radius: 999px; |
| 342 | font-size: 13px; |
| 343 | color: var(--alc-fg-2); |
| 344 | transition: |
| 345 | border-color 200ms ease, |
| 346 | background 200ms ease, |
| 347 | color 200ms ease; |
| 348 | } |
| 349 | .tl__modes label:hover span { |
| 350 | color: var(--alc-fg-1); |
| 351 | } |
| 352 | .tl__modes input:checked + span { |
| 353 | border-style: solid; |
| 354 | border-color: var(--alc-accent); |
| 355 | background: color-mix(in srgb, var(--alc-accent) 12%, transparent); |
| 356 | color: var(--alc-fg-1); |
| 357 | } |
| 358 | .tl__modes input:focus-visible + span { |
| 359 | outline: 2px solid var(--alc-accent); |
| 360 | outline-offset: 2px; |
| 361 | } |
| 362 | |
| 363 | /* ---------- layout ---------- */ |
| 364 | .tl__stage { |
| 365 | display: grid; |
| 366 | grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr); |
| 367 | gap: 24px; |
| 368 | align-items: center; |
| 369 | } |
| 370 | .tl__loop { |
| 371 | margin: 0; |
| 372 | text-align: center; |
| 373 | } |
| 374 | .tl__loop svg { |
| 375 | width: 100%; |
| 376 | max-width: 340px; |
| 377 | } |
| 378 | /* Panels stack in one grid cell; the active one is shown. */ |
| 379 | .tl__side, |
| 380 | .tl__notes { |
| 381 | display: grid; |
| 382 | } |
| 383 | .tl__panel, |
| 384 | .tl__note { |
| 385 | grid-area: 1 / 1; |
| 386 | min-width: 0; |
| 387 | opacity: 0; |
| 388 | visibility: hidden; |
| 389 | transition: |
| 390 | opacity 220ms ease, |
| 391 | visibility 0s linear 220ms; |
| 392 | } |
| 393 | .tl[data-tool="alchemy"] [data-for="alchemy"], |
| 394 | .tl[data-tool="sst"] [data-for="sst"], |
| 395 | .tl[data-tool="pulumi"] [data-for="pulumi"], |
| 396 | .tl[data-tool="terraform"] [data-for="terraform"] { |
| 397 | opacity: 1; |
| 398 | visibility: visible; |
| 399 | transition: opacity 320ms ease 100ms; |
| 400 | } |
| 401 | |
| 402 | /* ---------- loop ---------- */ |
| 403 | .tl__track { |
| 404 | fill: none; |
| 405 | stroke: var(--alc-hairline); |
| 406 | stroke-width: 1; |
| 407 | } |
| 408 | /* Each arc has one path per state; the current state's path shows. */ |
| 409 | .tl__arc path { |
| 410 | fill: none; |
| 411 | stroke-width: 2; |
| 412 | opacity: 0; |
| 413 | transition: opacity 320ms ease; |
| 414 | } |
| 415 | .tl__arc[data-state="ok"] .is-ok, |
| 416 | .tl__arc[data-state="diy"] .is-diy, |
| 417 | .tl__arc[data-state="broken"] .is-broken { |
| 418 | opacity: 1; |
| 419 | } |
| 420 | .tl__arc .is-ok { |
| 421 | stroke: var(--ok); |
| 422 | stroke-dasharray: 5 6; |
| 423 | animation: tl-flow 1.4s linear infinite; |
| 424 | } |
| 425 | .tl__arc .is-diy { |
| 426 | stroke: var(--diy); |
| 427 | stroke-dasharray: 5 6; |
| 428 | } |
| 429 | .tl__arc .is-broken { |
| 430 | stroke: var(--broken); |
| 431 | stroke-dasharray: 1 8; |
| 432 | stroke-linecap: round; |
| 433 | } |
| 434 | .tl__arrow.is-ok path { |
| 435 | fill: var(--ok); |
| 436 | } |
| 437 | .tl__arrow.is-diy path { |
| 438 | fill: var(--diy); |
| 439 | } |
| 440 | @keyframes tl-flow { |
| 441 | to { |
| 442 | stroke-dashoffset: -22; |
| 443 | } |
| 444 | } |
| 445 | .tl__node circle { |
| 446 | fill: var(--alc-bg); |
| 447 | stroke: color-mix(in srgb, var(--ok) 70%, transparent); |
| 448 | stroke-width: 1.5; |
| 449 | transition: |
| 450 | stroke 320ms ease, |
| 451 | fill 320ms ease, |
| 452 | stroke-dasharray 320ms ease; |
| 453 | } |
| 454 | .tl__node[data-state="diy"] circle { |
| 455 | stroke: var(--diy); |
| 456 | stroke-dasharray: 5 4; |
| 457 | } |
| 458 | .tl__node[data-state="broken"] circle { |
| 459 | stroke: var(--broken); |
| 460 | fill: color-mix(in srgb, var(--broken) 16%, var(--alc-bg)); |
| 461 | } |
| 462 | .tl__label { |
| 463 | font: 600 13px var(--alc-font-sans); |
| 464 | fill: var(--alc-fg-1); |
| 465 | transform: translateY(6px); |
| 466 | transition: transform 320ms ease; |
| 467 | } |
| 468 | .tl__node[data-timed] .tl__label { |
| 469 | transform: none; |
| 470 | } |
| 471 | .tl__time { |
| 472 | font: 10.5px var(--alc-font-mono); |
| 473 | fill: var(--alc-fg-3); |
| 474 | opacity: 0; |
| 475 | transition: opacity 320ms ease; |
| 476 | } |
| 477 | .tl__node[data-state="diy"] .tl__time { |
| 478 | fill: var(--diy); |
| 479 | } |
| 480 | .tl__node[data-state="broken"] .tl__time { |
| 481 | fill: var(--broken); |
| 482 | } |
| 483 | .tl__center { |
| 484 | opacity: 0; |
| 485 | transition: opacity 320ms ease; |
| 486 | } |
| 487 | .tl__total { |
| 488 | font: 500 30px var(--alc-font-serif); |
| 489 | fill: var(--alc-fg-1); |
| 490 | } |
| 491 | .tl__caption { |
| 492 | font: 12px var(--alc-font-sans); |
| 493 | fill: var(--alc-fg-3); |
| 494 | } |
| 495 | .tl[data-tool="alchemy"] [data-for="alchemy"], |
| 496 | .tl[data-tool="sst"] [data-for="sst"], |
| 497 | .tl[data-tool="pulumi"] [data-for="pulumi"], |
| 498 | .tl[data-tool="terraform"] [data-for="terraform"] { |
| 499 | opacity: 1; |
| 500 | } |
| 501 | |
| 502 | /* ---------- note ---------- */ |
| 503 | .tl__notes { |
| 504 | margin-top: 20px; |
| 505 | } |
| 506 | .tl__note h3 { |
| 507 | margin: 0 0 6px; |
| 508 | font-family: var(--alc-font-serif); |
| 509 | font-weight: 500; |
| 510 | font-size: 22px; |
| 511 | line-height: 1.25; |
| 512 | color: var(--alc-fg-1); |
| 513 | } |
| 514 | .tl__note p { |
| 515 | max-width: 680px; |
| 516 | margin: 0; |
| 517 | font-size: 15px; |
| 518 | line-height: 1.6; |
| 519 | color: var(--alc-fg-2); |
| 520 | } |
| 521 | .tl__note code { |
| 522 | font-family: var(--alc-font-mono); |
| 523 | font-size: 0.92em; |
| 524 | color: var(--alc-fg-1); |
| 525 | } |
| 526 | |
| 527 | /* ---------- legend ---------- */ |
| 528 | .tl__legend { |
| 529 | display: flex; |
| 530 | flex-wrap: wrap; |
| 531 | gap: 8px 18px; |
| 532 | margin: 16px 0 0; |
| 533 | padding: 0; |
| 534 | list-style: none; |
| 535 | font-size: 13px; |
| 536 | color: var(--alc-fg-3); |
| 537 | } |
| 538 | .tl__legend li { |
| 539 | display: inline-flex; |
| 540 | align-items: center; |
| 541 | gap: 8px; |
| 542 | } |
| 543 | .tl__legend span { |
| 544 | width: 12px; |
| 545 | height: 12px; |
| 546 | border-radius: 50%; |
| 547 | } |
| 548 | .tl__legend .is-ok { |
| 549 | background: color-mix(in srgb, var(--ok) 45%, transparent); |
| 550 | border: 1.5px solid var(--ok); |
| 551 | } |
| 552 | .tl__legend .is-diy { |
| 553 | border: 1.5px dashed var(--diy); |
| 554 | } |
| 555 | .tl__legend .is-broken { |
| 556 | background: color-mix(in srgb, var(--broken) 25%, transparent); |
| 557 | border: 1.5px solid var(--broken); |
| 558 | } |
| 559 | |
| 560 | @media (max-width: 900px) { |
| 561 | .tl__stage { |
| 562 | grid-template-columns: minmax(0, 1fr); |
| 563 | } |
| 564 | } |
| 565 | @media (prefers-reduced-motion: reduce) { |
| 566 | .tl__arc .is-ok { |
| 567 | animation: none; |
| 568 | } |
| 569 | } |
| 570 | </style> |