# stack-diagram

Adopt this skill: https://proskills.md/skills/other/caru-ini-stack-diagram-skills-stack-diagram

Page: https://proskills.md/skills/other/caru-ini-stack-diagram-skills-stack-diagram
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: skills/stack-diagram/SKILL.md
size: 20621
sha: 040f77124f9f31532491eb005168fee05adc9248
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/SKILL.md

````text
---
name: stack-diagram
description: "Create cloud-architecture-style diagrams of tech stacks, system architectures, infra layouts, and service topologies — with real brand logos, grouped containers, and clean orthogonal arrows — delivered as self-contained HTML, a PNG image, a React/TSX component, or a live artifact. Use this skill whenever the user asks for an architecture / system / stack / infra diagram, a tech-stack overview, a service topology, a cloud-vendor-style diagram, or wants tech logos arranged into a picture of how their services connect — in any language, even if they don't name an output format, and even if the tools involved aren't cloud services."
license: MIT (see LICENSE); bundled fonts are SIL OFL 1.1
compatibility: Python 3.9+ and network access to fetch logos; PNG output also needs Playwright with Chromium
---

# stack-diagram

Produces engineering-drawing-style architecture diagrams: real logos on white tiles, labeled group boxes (like cloud-vendor VPC diagrams), orthogonal arrows with monospace labels, on a cool graph-paper background. One self-contained HTML file is the source of truth; PNG / TSX / artifact are derived from it.

## Requirements

- Python 3.9+ (standard library only for `fetch_icons.py`, `build.py` and `share.py`)
- Network access to the icon sources (svgl.app, raw.githubusercontent.com, cdn.jsdelivr.net, aws.amazon.com and d1.awsstatic.com for AWS icons, services.google.com for Google Cloud icons, learn.microsoft.com and arch-center.azureedge.net for Azure icons, plus the brand's own site or repo for `name=URL`) when fetching icons
- Playwright + Chromium for `render_png.py`: `pip install playwright && python3 -m playwright install --with-deps chromium`

## Workflow

1. Settle the content (nodes, groups, edges)
2. Fetch icons — `scripts/fetch_icons.py`
3. Plan coordinates — read `references/geometry.md`
4. Build the HTML from `assets/template.html` — `scripts/build.py`
5. **Render a PNG and look at it** before delivering
6. Deliver in the requested format(s)
7. Optionally share it to the public gallery

## Step 1 — Content

From the user's list of technologies/services, decide:

- **Nodes**: each tech = one node (name + a short lowercase role like `http server`, `sqlite db`)
- **Groups**: draw a box around things that share a runtime, vendor, or repo (e.g. a cloud provider box containing a runtime box; a monorepo box for tooling). Nesting one level deep is fine; deeper gets noisy. Write group labels in the brand's own casing (`GitHub`, `npm`); the template shows them as written.
- **Edges**, three kinds:
  - accent-colored solid = the primary request/data flow (one continuous path reads best)
  - neutral solid = secondary internal connections
  - neutral dashed = dev-time / deploy-time relationships (CI, git hooks, IaC deploy)

If the user gave only a flat list of tools, infer sensible groups and flows from what the tools are — only ask when a tool is ambiguous enough that the diagram would be wrong either way.

## Step 2 — Icons

```bash
python3 scripts/fetch_icons.py <icons-dir> tanstack hono drizzle "betterauth:better-auth" "orpc=https://orpc.unnoq.com/logo.webp"
python3 scripts/fetch_icons.py <icons-dir> "aws:aws/AWS-Cloud-logo" "vpc:aws/vpc" "fargate:aws/fargate" "s3:aws/s3"
python3 scripts/fetch_icons.py <icons-dir> "gcp:google-cloud" "run:gcp/cloud-run" "lb:gcp/load-balancing" "sql:gcp/cloud-sql"
python3 scripts/fetch_icons.py <icons-dir> "azure:azure/Azure-A" "app:azure/app-service" "vnet:azure/vnet" "sql:azure/sql-database"
```

Spec forms: `name` (name used as slug), `name:slug` (different slug), `name=URL` (explicit URL), `name:aws/<Name>` (official AWS icon), `name:gcp/<Product>` (official Google Cloud icon), `name:azure/<Name>` (official Azure icon). The script tries, in order: **svgl → devicon → gilbarbara/logos**, writes files plus `manifest.json`, and flags icons whose fill is white. svgl (svgl.app) is first because it carries current official marks for modern dev brands (TanStack, Better Auth, Hono...). When a logo has light/dark versions, the light one becomes `<name>` (tiles are white in both themes) and the dark one is saved as `<name>_dark` for group labels. svgl is matched by exact title only, so a miss just falls through. Its API also exposes `wordmark` variants — occasionally useful for a wide group-label, never for tiles.

Rules that matter more than the mechanics:

- **Never recolor a logo.** Users notice. If a logo is white or too light for a white tile, put it on a dark tile (`tile dark` class) instead of touching its fill. The manifest's `white_fill` flag is a heuristic — a white fill can be an inner detail on a colored mark (tRPC, Ubuntu), so glance at the SVG before reaching for the dark tile.
- **Official marks only.** simple-icons is deliberately not a source: its glyphs are monochrome redraws, and users call them wrong on sight. When every source misses (`FAIL`), find the official file and pass it as `name=URL` — look in the project's repo (root `logo*.svg`, `docs/`, `assets/`, `public/`) and on its site (`/logo.svg`, `/favicon.svg`, `/images/logo.svg`). Some marks live only in the repo (Lefthook's is `logo_sign.svg` at the root of `evilmartians/lefthook`).
- **Abstract concepts get pictograms, not text.** A node like "dev environment", "cron", "queue", "cache", or "webhook receiver" has no brand — reach for a Lucide line icon via `name:lucide/<icon>` (e.g. `devenv:lucide/terminal`, `cron:lucide/clock`; browse lucide.dev/icons). No text-in-a-tile, no emoji.
- **Vendor sub-products** (Cloudflare D1/R2/KV...) usually have no distinct logo: just reuse the vendor logo on the tile and let the node name carry the distinction ("Cloudflare D1" / "sqlite db").
- **AWS uses AWS's own icons.** Each service has one: fetch it with `name:aws/<Name>`, read from AWS's official icon package at run time (e.g. `lambda:aws/lambda`, `aws:aws/AWS-Cloud-logo`, `s3:aws/s3`). The `AWS-` / `Amazon-` prefix is optional, common acronyms work (`s3`, `sqs`, `sns`, `ses`, `ecs`, `eks`, `ecr`, `elb`, `alb`, `nlb`, `iam`, `kms`, `msk`, `vpc`), and a miss prints every name. The slugs `aws`, `amazonwebservices` and `aws-*` go to the same package, and what it doesn't carry `FAIL`s instead of falling back to a third-party copy. Always write the `aws/` prefix for a service: a bare `dynamodb` still goes through the third-party chain. Keep the package and loose icon files out of repos: AWS allows the icons in diagrams, and redistribution is unconfirmed.
- **The AWS area follows AWS's own drawing rules.** Service icons carry their own square and work on light and dark grounds, so use `tile bare`: a white tile only doubles the frame. People and generic nodes inside it use AWS's general icons (`user:aws/General-User`; also `General-Users`, `General-Client`, `General-Internet`, `General-Server`) in `tile bare res` with the template's `lt`/`dk` pair. Groups use the template's `group aws` for AWS Cloud (with `aws:aws/AWS-Cloud-logo` in the label), plus `region`, `az`, `vpc`, `private` or `public` for the rest: square corners, outline only, label in ink, no vendor tint, and a 30px icon flush in the corner instead of the 14px one. Inside a VPC, show public and private subnets when you know or can safely infer the split (an internet-facing load balancer in public; app servers and databases in private): a flat VPC is the first thing AWS people notice. Edges inside it use one color and an open arrowhead: `<path class="aws" ... marker-end="url(#open)"/>`, `aws dash` for dev/deploy, plain `.el` labels, and no legend for them. Name nodes `Amazon S3` / `AWS Fargate`, not a bare `S3` / `Fargate`; a short form like `ALB` goes in the role line, under the full name (`Elastic Load Balancing`). Names may take two lines, but `Amazon` / `AWS` must stay on the first word's line: if it would wrap right after the prefix, widen that node (`left:<centerX-76>px; width:152px`).
- **Google Cloud uses Google's own icons.** Fetch them with `name:gcp/<Product>`, read from Google's official packages at run time (e.g. `run:gcp/cloud-run`, `lb:gcp/load-balancing`, `memorystore:gcp/memorystore`). Only core products have their own icon (Cloud Run, Cloud SQL, Cloud Storage, BigQuery, GKE, Compute Engine, Vertex AI...). Every other product uses its category's icon, as Google's guide specifies: Cloud Load Balancing and Cloud CDN both get Networking, Memorystore and Firestore both get Databases. Write the product's name and the script picks the icon. The `Cloud` / `Google` prefix is optional, some short forms work (`vpc`, `iam`, `kms`, `iap`, `gce`, `gcs`), and a miss prints every name. `gcp/storage` is the Storage category; Cloud Storage is `gcp/cloud-storage`. The slugs `gcp-*` and `google-cloud-*` go to the same packages, and what they don't carry `FAIL`s instead of falling back to a third-party copy: those copies are the retired hexagon icons, which Google says not to use. Always write the `gcp/` prefix for a product: a bare `bigquery` still goes through the third-party chain. `gcp:google-cloud` is the Google Cloud mark for the group label. Gemini and Firebase aren't in the packages: use the bare `gemini` and `firebase`. Keep the packages and loose icon files out of repos: Google offers the icons for diagrams and documentation, and redistribution is unconfirmed.
- **Google Cloud diagrams keep the normal style, with Google's groups.** Google's own diagrams (solid blue panel, horizontal cards) are deliberately not copied: icons go on the normal white tile, and edges and the legend stay as usual. Groups use the template's `group gcp` for Google Cloud (with `gcp:google-cloud` in the label), plus `vpc`, `region`, `subnet` or `zone`. A VPC is global and a subnet is regional, so put the region inside the VPC, never the VPC inside a region. Global and serverless services (an external load balancer, Cloud Storage, Cloud Run) stay outside the VPC. Because icons are shared, always write the product's full name (`Cloud Load Balancing`, not `LB`), and widen a node whose name would wrap (`left:<centerX-76>px; width:152px`).
- **Azure uses Microsoft's own icons.** Each service has one: fetch it with `name:azure/<Name>`, read from Microsoft's official package at run time (e.g. `app:azure/app-service`, `azure:azure/Azure-A`, `vault:azure/key-vault`). The `Azure-` / `Microsoft-` prefix and a plural `s` are optional, common short forms work (`aks`, `vm`, `vnet`, `nsg`, `waf`, `blob`, `cosmos`, `redis`, `postgres`, `functions`, `front-door`, `apim`, `ai-search`), and a miss prints every name. The slugs `azure`, `microsoft-azure` and `azure-*` go to the same package, and what it doesn't carry `FAIL`s instead of falling back to a third-party copy. Always write the `azure/` prefix for a service: a bare `cosmos` still goes through the third-party chain. Microsoft Entra ID has a separate icon set: pass its official file as `name=URL`. Microsoft allows these icons in architecture diagrams, training materials and documentation only. Never crop, flip, rotate or reshape one, use it only for the Microsoft service it stands for, and keep the package and loose icon files out of repos.
- **Azure diagrams keep the normal style, with Microsoft's group colors.** Microsoft's own diagrams are deliberately not copied: icons go on the normal white tile, and edges and the legend stay as usual. Groups use the template's `group azure` for Azure (with `azure:azure/Azure-A` in the label), plus `rg`, `region`, `vnet`, `subnet` or `zone`, and a `vnet` or `subnet` label carries its icon (`vnet:azure/vnet`, `subnet:azure/subnet`). A VNet belongs to one region, so it goes inside the region. Global services like Front Door stay outside the region. App Service sits outside the VNet unless it runs in an App Service Environment, and a PaaS service reached through a private endpoint can sit inside it with `private endpoint` as its role. Microsoft asks for the service's full name beside each icon (`Azure SQL Database`, not `SQL`), so widen a node whose name would wrap (`left:<centerX-76>px; width:152px`).
- **A vendor's rules cover only its own area.** The AWS, Google Cloud and Azure rules apply only inside that vendor's group, even when the diagram shows nothing else. Everything outside it, such as a user beside it or an edge that crosses into it, keeps the normal tiles, groups, edges and legend.
- **Non-brand nodes** (user, browser, internet, devices, bare-metal server): copy a glyph from `assets/generic/` into the icons dir under the node's name, or use `name:lucide/<icon>` — don't use emoji. These aren't brand logos, so tinting them to the diagram's ink color is fine (the fetch script does it for Lucide automatically).
- **Group-label icons render at 14px** — they need a square glyph mark. Wordmarks and wide lockups turn to mud at that size; if the main icon is a wordmark, fetch the brand's square icon separately for the label (svgl usually has it; otherwise the project's own icon or favicon file).
- **Group labels sit on the group's tint, not on a white tile**, so in the dark theme a mark with black parts vanishes there. If `fetch_icons.py` saved `<name>_dark`, use the template's `lt`/`dk` pair in the label; if there is no dark variant and the mark has black parts, add class `plate` to the label's `<img>` (white backing, dark theme only).
- Raster logos (png/webp) are fine; the build script embeds everything as data URIs, so nothing is loaded at view time.

## Step 3 — Layout

Read `references/geometry.md` for the coordinate system, spacing constants, and — most importantly — the **edge anchoring rules that prevent arrows from running through label text**. The one-line version: a node is its tile *plus* the text under it; vertical arrows connect node-box boundaries, never tile-to-tile through text.

## Step 4 — Build

Copy `assets/template.html`, replace the sample GROUPS / EDGES / NODES / header sections with yours (the template's markup doubles as format documentation), then inline the assets. Set `lang` on `<html>` to the language of the diagram's labels (`en` by default; use `ja` for Japanese so browsers pick Japanese glyph variants). Two options the template already carries: a **dark variant** (set `data-theme="dark"` on `<html>`; tiles stay white so logos survive, group-label icons follow the Step 2 rule, and arrowheads and legend swatches follow the tokens automatically), and a "deploy to platform X" edge where X is a group, not a node — point the arrow at the most representative node *inside* the group and label the edge (`deploy`), which reads correctly.

```bash
python3 scripts/build.py my-diagram.html <icons-dir> out.html --fonts <skill>/assets/fonts
```

Icons are referenced in markup as `%%ICON_<name>%%`; fonts as `%%FONT_DISPLAY%%` (Geist) / `%%FONT_JBM%%` (JetBrains Mono). The build fails loudly on unresolved tokens. Keep every color and font behind the CSS custom properties already in the template — tweak the tokens, not scattered values.

## Step 5 — Verify by looking

Render and actually read the image:

```bash
python3 scripts/render_png.py out.html check.png --selector .canvas --scale 2
```

If the diagram contains Japanese text, the render host needs a Japanese font, or CJK characters silently come out in Chinese glyph variants (users notice immediately). The script warns and prints a fix; the template's font stack already names the Noto JP faces.

Checklist while looking at it:
- No arrow crosses label text; no arrowhead lands inside text
- Edge labels readable (they have a paper-colored halo — confirm it's working)
- Every icon visible (white-on-white is the classic failure → dark tile), including group-label icons in the dark render
- Group labels sit in their group's top-left corner, clear of nodes and edges; each group's left and right padding match
- Nothing sticks out of its group box; nothing clipped at canvas edge

Fix and re-render until clean. This loop is cheap; shipping a diagram with overlapping text is not.

## Step 6 — Deliver

- **HTML** (default): the built file itself. Self-contained, works offline.
- **PNG**: always produced by screenshotting the built HTML with headless Chromium (`render_png.py`, `--scale 2` for crisp text) — never by drawing a separate image with matplotlib/PIL/standalone SVG rasterizers; the HTML is the single source of truth and the browser's text/SVG rendering is what makes it look right. By default the script emits BOTH themes (`out.png` light + `out-dark.png`) with square corners (radius is stripped for the screenshot only) — deliver both unless the user asked for one; `--single` captures the document as-authored. Target `.canvas` for just the diagram, `--full-page` for the whole page.
- **TSX**: convert per `references/tsx.md` (self-contained React component, no deps).
- **Artifact** (where the environment offers an artifact or page-publishing tool): publish the built HTML with the environment's artifact tool. Everything is already inlined as data URIs, so the artifact's blocked-network sandbox is a non-issue. Prefer this when the user will iterate on the diagram in conversation; pair it with a PNG when they need something to paste elsewhere.

If several formats are requested, build the HTML once and derive the rest from it.

In the footer, credit the icon sources actually used — the `source` field of each entry in the icons dir's `manifest.json` (e.g. `icons: svgl / devicon / Lucide`; source `aws` reads `AWS Architecture Icons`, source `gcp` reads `Google Cloud product icons`, source `azure` reads `Azure Public Service Icons`). Brand logos remain the trademarks of their owners. Keep the template's "made with stack-diagram" link after the credits unless the user asks to remove it; a default PNG captures only the canvas, so the link stays out of it.

## Step 7 — Share to the gallery (optional)

Offer this only where you can run shell commands with network access; otherwise skip the step. After delivering, add one line in the user's language offering to share it, with the gallery link so they can look first. For example: "If you'd like to share this diagram, I can post it to the public gallery: https://stacks.caru.moe". Say it once per conversation and never raise it again; never submit without the user's explicit yes.

On a yes, ask for a display name (don't fill it in from `gh` or git) and whether to add a GitHub login. The login is optional and never shown publicly; it only lets them delete the post later by signing in to the gallery with that account. If `gh` is signed in, offer the result of `gh api user -q .login`; include it only if they agree.

Then show exactly what will be sent before sending anything:

- the light and dark PNGs — the `.canvas` pair `render_png.py` writes (`out.png` / `out-dark.png`) — with their paths and every label in them
- a title, in plain English even when the conversation isn't. It describes the diagram, not the request. The diagram's own labels stay as they are.
- the stack: the technologies in the diagram, by their official names
- the display name
- the GitHub login, if any, marked as not public

Point out anything that looks internal — hostnames, IPs, customer or company names, internal service names — and suggest re-rendering without it first. Ask them to confirm it is their own system or one they may show publicly, and send only after a second explicit yes on that exact content.

```bash
python3 scripts/share.py submit out.png out-dark.png --title "..." --stack "..." --name "..." [--github LOGIN] [--icons DIR]
```

`--stack` lists the technologies, comma-separated, by their official names. Write `Name=key` to show a technology's logo beside it in the gallery: `key` is its entry in the icons dir's `manifest.json`, and `--icons` takes that dir (`<icons-dir>` from Step 2). A name without `=` goes without a logo, for example `--stack "Next.js=nextjs,Hono=hono,Neon" --icons <icons-dir>`. The gallery shows only logos from svgl, gilbarbara/logos and devicon; AWS, Google Cloud and Azure product icons, Lucide and logos from a brand's own site or repo go without one.

If `share.py` rejects a PNG as too large, re-render with a lower `--scale` and show the new pair before sending.

Tell the user the URL, that a held post appears only after a manual review, and the path where `share.py` says it saved the delete key. To delete later: `python3 scripts/share.py delete <id>` (`list` shows saved posts). If the gallery could confirm the GitHub login they gave, signing in with that account on the gallery deletes it too.
````

## File: skills/stack-diagram/assets/fonts/README.md
size: 448
sha: ef6544c71a4471d073b5d916e89e2e87dfcbc65d
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/fonts/README.md

```text
# Bundled fonts

| File | Font | License |
|---|---|---|
| `Geist.woff2` | Geist 1.800 (variable, subset) | SIL OFL 1.1 — see `OFL-Geist.txt` |
| `JetBrainsMono.woff2` | JetBrains Mono (variable, subset) | SIL OFL 1.1 — see `OFL-JetBrainsMono.txt` |

Both files are Latin subsets of the upstream fonts in WOFF2, unchanged otherwise (`Geist.woff2` is the Fontsource Latin build). `build.py` embeds them into each generated diagram as data URIs.
```

## File: skills/stack-diagram/scripts/build.py
size: 1830
sha: 1041769738ed0042406cbfd1952346d4247d0dc8
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/scripts/build.py

```text
#!/usr/bin/env python3
"""Inline fonts and icons into a diagram HTML (or TSX) file as data URIs.

Usage:
  python3 build.py DRAFT ICONDIR OUT [--fonts FONTSDIR]

Replaces:
  %%FONT_DISPLAY%%  base64 of FONTSDIR/Geist.woff2
  %%FONT_JBM%%      base64 of FONTSDIR/JetBrainsMono.woff2
  %%ICON_<name>%%   data URI of ICONDIR/<name>.(svg|png|webp)

Fails loudly if any %%...%% token is left unresolved.
"""
import base64, pathlib, re, sys

MIME = {"svg": "image/svg+xml", "png": "image/png", "webp": "image/webp",
        "jpg": "image/jpeg", "jpeg": "image/jpeg"}

def b64(p): return base64.b64encode(pathlib.Path(p).read_bytes()).decode()

def main():
    args = sys.argv[1:]
    fontsdir = None
    if "--fonts" in args:
        i = args.index("--fonts"); fontsdir = pathlib.Path(args[i + 1]); del args[i:i + 2]
    if len(args) != 3:
        print(__doc__); sys.exit(1)
    draft, icondir, out = pathlib.Path(args[0]), pathlib.Path(args[1]), pathlib.Path(args[2])
    html = draft.read_text(encoding="utf-8")

    if fontsdir:
        for tok, fn in (("%%FONT_DISPLAY%%", "Geist.woff2"), ("%%FONT_JBM%%", "JetBrainsMono.woff2")):
            if tok in html:
                html = html.replace(tok, b64(fontsdir / fn))

    for f in sorted(icondir.iterdir()):
        if f.suffix.lstrip(".") not in MIME:
            continue
        tok = f"%%ICON_{f.stem}%%"
        if tok in html:
            html = html.replace(tok, f"data:{MIME[f.suffix.lstrip('.')]};base64,{b64(f)}")

    leftover = sorted(set(re.findall(r"%%[A-Za-z0-9_-]+%%", html)))
    if leftover:
        sys.exit(f"unresolved tokens: {', '.join(leftover)}\n"
                 f"(missing icon files in {icondir}? forgot --fonts?)")
    out.write_text(html, encoding="utf-8")
    print(f"built {out} ({len(html) // 1024} KB)")

if __name__ == "__main__":
    main()
```

## File: skills/stack-diagram/scripts/fetch_icons.py
size: 22751
sha: 11024d8e4f63697db8a98a2dcd9364c29fc7dd8b
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/scripts/fetch_icons.py

```text
#!/usr/bin/env python3
"""Fetch brand logos with a fallback chain, unmodified, for diagram builds.

Usage:
  python3 fetch_icons.py OUTDIR spec [spec ...]

Spec forms:
  name              -> slug == name          (e.g. hono)
  name:slug         -> different slug        (e.g. betterauth:better-auth)
  name=URL          -> explicit URL          (e.g. orpc=https://orpc.unnoq.com/logo.webp)
  name:aws/Service  -> official AWS icon     (e.g. lambda:aws/AWS-Lambda)
  name:gcp/Product  -> official Google Cloud icon (e.g. run:gcp/Cloud-Run)
  name:azure/Service -> official Azure icon  (e.g. vault:azure/Key-Vaults)

Sources tried in order:
  1. svgl              api.svgl.app?search={s} -> route (light variant when split)
  2. devicon           raw.githubusercontent.com/devicons/devicon/master/icons/{s}/{s}-original.svg
  3. gilbarbara/logos  cdn.jsdelivr.net/gh/gilbarbara/logos/logos/{s}-icon.svg then {s}.svg

simple-icons is deliberately not a source: its glyphs are monochrome redraws, not the
official marks. When every source misses, pass the official file as name=URL.

aws/ specs and the slugs aws, amazonwebservices and aws-* skip the chain and read AWS's own
package (aws.amazon.com/architecture/icons): gilbarbara's aws-* files keep an older color set.

gcp/ specs and the slugs gcp-* and google-cloud-* do the same with Google's packages
(cloud.google.com/icons). The slugs gcp and google-cloud are the Google Cloud mark, through the chain.

azure/ specs and the slugs azure, microsoft-azure and azure-* do the same with Microsoft's package
(learn.microsoft.com/azure/architecture/icons).

svgl is matched by normalized title equality (search is fuzzy — a loose match would
silently ship the wrong brand's logo, so non-exact hits fall through to next source).

Writes OUTDIR/<name>.<ext> and OUTDIR/manifest.json, plus OUTDIR/<name>_dark.<ext> when
the source has a dark-theme variant (for group labels). Flags white/near-white fills
(use a dark tile for those). Never modifies icon colors.
"""
import base64, functools, http.client, io, json, pathlib, re, sys, urllib.request, urllib.error, urllib.parse, zipfile

UA = {"User-Agent": "Mozilla/5.0 (stack-diagram-skill)"}
NAME_RE = re.compile(r"[A-Za-z0-9_-]+")   # becomes a filename and an %%ICON_<name>%% token
AWS_ICONS_PAGE = "https://aws.amazon.com/architecture/icons/"
AWS_PACKAGE_RE = re.compile(r"""(?:https:)?//[\w.-]+\.awsstatic\.com/[^"'\s]+/Icon-package_[^"'\s/]+\.zip""")
AWS_ICON_RE = re.compile(r"Architecture-Service-Icons_[^/]+/[^/]+/64/Arch_(.+)_64\.svg"
                         r"|Architecture-Group-Icons_[^/]+/(.+)_32\.svg"
                         r"|Resource-Icons_[^/]+/Res_General-Icons/Res_48_Light/Res_(.+)_48_Light\.svg")
AWS_ALIASES = {
    "s3": "Amazon-Simple-Storage-Service", "glacier": "Amazon-Simple-Storage-Service-Glacier",
    "sqs": "Amazon-Simple-Queue-Service", "sns": "Amazon-Simple-Notification-Service",
    "ses": "Amazon-Simple-Email-Service", "ecs": "Amazon-Elastic-Container-Service",
    "eks": "Amazon-Elastic-Kubernetes-Service", "ecr": "Amazon-Elastic-Container-Registry",
    "elb": "Elastic-Load-Balancing", "alb": "Elastic-Load-Balancing", "nlb": "Elastic-Load-Balancing",
    "iam": "AWS-Identity-and-Access-Management", "kms": "AWS-Key-Management-Service",
    "msk": "Amazon-Managed-Streaming-for-Apache-Kafka", "opensearch": "Amazon-OpenSearch-Service",
    "vpc": "Virtual-private-cloud-VPC",
}
GCP_PACKAGES = ("https://services.google.com/fh/files/misc/core-products-icons.zip",
                "https://services.google.com/fh/files/misc/category-icons.zip")
GCP_ICON_RE = re.compile(r"(?:Unique|Category) Icons/([^/]+)/SVG/[^/]+\.svg")
# Icon folder -> products, from Google's guide on cloud.google.com/icons; its bare "SQL" row is left out so gcp/sql is Cloud SQL
GCP_PRODUCTS = {
    "Agents": "AI Applications & Agents, AI Testing agent, Automotive AI agent, Code Documentation agent, "
              "Deep Research agent, Food ordering AI agent, Idea Generation agent, Customer Engagement Suite, "
              "Conversational Agents, Agent Assist, Conversational Insights, Contact Center as a Service",
    "AI _ Machine Learning": "AI and Machine Learning, Advanced Agent Modeling, AI Hub, AI Platform, "
              "AI Platform Unified, AutoML, AutoML Natural Language, AutoML Tables, AutoML Translation, "
              "AutoML Video Intelligence, AutoML Vision, Cloud GPU, Cloud Healthcare API, Cloud Natural Language API, "
              "Cloud Optimization AI, Cloud Optimization AI FleetRouting API, Cloud TPU, Cloud Translation API, "
              "Cloud Vision API, Data Labeling, Dialogflow ES, Document AI, Genomics, Healthcare NLP API, "
              "Media Translation API, Recommendations AI, Speech to Text, Tensorflow Enterprise, Text To Speech, "
              "Visual Inspection AI",
    "Collaboration": "Google Workspace",
    "Compute": "Batch, Container Optimized OS, GCE Systems Management, Migrate To Virtual Machines, "
               "OS Config Management, OS Inventory Management, OS Patch Management, VMware Engine",
    "Containers": "Backup for GKE, Knative serving, Migrate To Containers",
    "Data Analytics": "Analytics Hub, BigLake, Cloud Composer, Cloud Data Fusion, Data Catalog, Data Layers, "
                      "Data Loss Prevention API, Dataflow, Dataplex, Datapol, Dataprep, Dataproc, Dataproc Metastore, "
                      "Datashare, Datastream, Pub/Sub",
    "Databases": "Bare Metal Solution, Bigtable, Database Center, Database Migration Service, Datastore, Firestore, "
                 "Memorystore",
    "Developer Tools": "Cloud Code, Cloud Deployment Manager, Cloud Scheduler, Cloud Shell, Cloud Tasks, "
                       "Cloud Workstations, Runtime Config, Service Catalog, Tools For Powershell",
    "DevOps": "Artifact Registry, Cloud Build, Cloud Deploy, Container Registry",
    "Hybrid & Multicloud": "Hybrid and Multicloud",
    "Integration Services": "Advanced API Security, API Analytics, Application Integration, Cloud API Gateway, "
                            "Cloud APIs, Cloud Endpoints, Connectors, Developer Portal, Eventarc, Workflows",
    "Management Tools": "Carbon Footprint, Producer Portal",
    "Maps & Geospatial": "Maps and Geospatial, Earth Engine, Google Earth, Google Maps Platform",
    "Marketplace": "Google Cloud Marketplace",
    "Migration": "Migrate For Compute Engine, Transfer Appliance",
    "Networking": "Cloud Armor, Cloud CDN, Cloud DNS, Cloud Domains, Cloud Firewall Rules, Cloud IDS, "
                  "Cloud Interconnect, Cloud Load Balancing, Load Balancer, Cloud NAT, Cloud Network, Cloud Router, "
                  "Cloud VPN, Connectivity Test, Data Transfer, Network Connectivity Center, "
                  "Network Intelligence Center, Network Security, Network Tiers, Network Topology, Packet Mirroring, "
                  "Partner Interconnect, Premium Network Tier, Private Connectivity, Private Service Connect, Routes, "
                  "Service Mesh, Virtual Private Cloud, VPC",
    "Observability": "Cloud Audit Logs, Cloud Logging, Cloud Monitoring, Error Reporting, "
                     "Google Cloud Observability, Profiler, Trace",
    "Operations": "App Hub, Backup and DR, Capacity Planner, Performance Dashboard, Personalized Service Health",
    "Security Identity": "Security and Identity, Access Context Manager, Asset Inventory, Assured Workloads, "
                         "BeyondCorp, Binary Authorization, Certificate Authority Service, Certificate Manager, "
                         "Cloud Asset Inventory, Cloud External Key Manager, Cloud HSM, Cloud Security Scanner, "
                         "Identity and Access Management, IAM, Identity Aware Proxy, IAP, Identity Platform, "
                         "Key Access Justifications, Key Management Service, KMS, "
                         "Managed Service For Microsoft Active Directory, Phishing Protection, Policy Analyzer, "
                         "Recaptcha Enterprise, Risk Manager, Secret Manager, Security Key Enforcement, Web Risk, "
                         "Web Security Scanner, Workload Identity Federation",
    "Serverless Computing": "Serverless, App Engine, Cloud Functions, Cloud Run functions",
    "Storage": "Filestore, Google Cloud NetApp Volumes, Local SSD, Parallelstore, Storage Transfer Service",
    "Web & Mobile": "Web and Mobile",
    "Web3": "Blockchain Node Engine, Blockchain RPC",
    "AlloyDB": "AlloyDB Omni",
    "BigQuery": "BigQuery Functions, BigQuery Omni",
    "Cloud Spanner": "Spanner",
    "Cloud Storage": "GCS",
    "Compute Engine": "GCE",
    "Distributed Cloud": "Google Distributed Cloud",
    "GKE": "Google Kubernetes Engine, Kubernetes Engine",
    "Hyperdisk": "Persistent Disk",
    "Looker": "Looker Studio, Looker Studio Pro",
    "Security Operations": "Google Security Operations, SecOps",
    "Threat Intelligence": "Google Threat Intelligence",
}
AZURE_ICONS_PAGE = "https://learn.microsoft.com/en-us/azure/architecture/icons/"
AZURE_PACKAGE_RE = re.compile(r"https://arch-center\.azureedge\.net/icons/Azure_Public_Service_Icons_V\d+\.zip")
AZURE_ICON_RE = re.compile(r"Azure_Public_Service_Icons/Icons/[^/]+/\d+\s*-icon-service-(.+)\.svg")
AZURE_ALIASES = {
    "webapp": "App-Services", "functions": "Function-Apps", "azurefunctions": "Function-Apps",
    "aks": "Kubernetes-Services", "vm": "Virtual-Machine", "vmss": "VM-Scale-Sets",
    "containerapp": "Container-Apps-Environments", "containerapps": "Container-Apps-Environments",
    "acr": "Container-Registries", "aci": "Container-Instances", "staticwebapp": "Static-Apps",
    "staticwebapps": "Static-Apps", "frontdoor": "Front-Door-and-CDN-Profiles",
    "appgateway": "Application-Gateways", "lb": "Load-Balancers", "vnet": "Virtual-Networks",
    "nsg": "Network-Security-Groups", "waf": "Web-Application-Firewall-Policies(WAF)", "dns": "DNS-Zones",
    "expressroute": "ExpressRoute-Circuits", "trafficmanager": "Traffic-Manager-Profiles",
    "vpngateway": "Virtual-Network-Gateways", "storage": "Storage-Accounts", "blob": "Storage-Accounts",
    "blobstorage": "Storage-Accounts", "cosmos": "Azure-Cosmos-DB", "redis": "Azure-Managed-Redis",
    "managedredis": "Azure-Managed-Redis", "postgres": "Azure-Database-PostgreSQL-Server",
    "postgresql": "Azure-Database-PostgreSQL-Server", "mysql": "Azure-Database-MySQL-Server",
    "eventgrid": "Event-Grid-Topics", "apim": "API-Management-Services", "apimanagement": "API-Management-Services",
    "foundry": "AI-Foundry", "aisearch": "Cognitive-Search", "search": "Cognitive-Search",
    "appinsights": "Application-Insights", "loganalytics": "Log-Analytics-Workspaces",
    "datafactory": "Data-Factories", "synapse": "Azure-Synapse-Analytics", "resourcegroup": "Resource-Groups",
    "rg": "Resource-Groups", "managedidentity": "Managed-Identities",
}
PACKAGES = {}

def get(url, timeout=15):
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.read()

def read(url):
    """Bytes of url; '<package URL>#<member>' reads from the already-downloaded zip."""
    zip_url, _, member = url.partition("#")
    return PACKAGES[zip_url].read(member) if zip_url in PACKAGES else get(url)

def package_from_page(page, link_re):
    """Download the zip a page links to, which changes with each release, and return its URL.
    It is read once per run and stays in memory."""
    link = link_re.search(get(page).decode("utf-8", "ignore"))
    if not link:
        raise OSError(f"no icon package link on {page}")
    url = urllib.parse.urljoin(page, link.group())
    PACKAGES[url] = zipfile.ZipFile(io.BytesIO(get(url)))
    return url

def looks_svg(b): return b.lstrip()[:200].lstrip().startswith((b"<svg", b"<?xml", b"<!DOC"))
def ext_of(b, url):
    if looks_svg(b): return "svg"
    if b[:8] == b"\x89PNG\r\n\x1a\n": return "png"
    if b[:4] == b"RIFF" and b[8:12] == b"WEBP": return "webp"
    m = re.search(r"\.(png|webp|jpe?g|svg)(\?|$)", url)
    return m.group(1) if m else None

def white_fill(b):
    """True only when the mark is white overall: at least one white paint and no
    non-white paint anywhere (a white detail on a colored logo is not a white logo)."""
    if not looks_svg(b): return False
    s = b.decode("utf-8", "ignore")
    paints = re.findall(r'(?:fill|stroke|stop-color)[=:]\s*"?([#a-zA-Z][^";\s>]*)', s)
    white = colored = False
    for p in paints:
        p = p.strip().lower()
        if p in ("none", "transparent", "currentcolor") or p.startswith("url("):
            continue
        if re.fullmatch(r"#f{3}(f{3})?|white", p): white = True
        else: colored = True
    return white and not colored

def norm(s): return re.sub(r"[^a-z0-9]", "", s.lower())

def svgl_lookup(slug):
    """Exact-normalized-title match on svgl's search API: (url, dark_url or None), or
    None if no exact hit. svgl's search matches title substrings ('better-auth' misses
    'Better Auth', 'nextjs' misses 'Next.js'), so also try the slug with separators
    turned into spaces and without a trailing 'js' (svgl titles Vue 'Vue', not 'Vue.js')."""
    queries = [slug, re.sub(r"[-_.]+", " ", slug).strip()]
    targets = {norm(slug)}
    if len(slug) > 4 and slug.endswith("js"):
        queries.append(slug[:-2]); targets.add(norm(slug[:-2]))
    for q in dict.fromkeys(queries):
        try:
            items = json.loads(get(f"https://api.svgl.app?search={urllib.parse.quote(q)}"))
        except Exception:
            continue
        for it in items if isinstance(items, list) else []:
            if norm(it.get("title", "")) in targets:
                route = it.get("route")
                if isinstance(route, dict):    # light/dark split: tiles are white -> light
                    light, dark = route.get("light"), route.get("dark")
                    return (light, dark) if light else (dark, None)
                return route, None
    return None

@functools.lru_cache(maxsize=1)
def aws_index():
    """AWS's icon package as {normalized name: (name, url, dark url or None)}."""
    url = package_from_page(AWS_ICONS_PAGE, AWS_PACKAGE_RE)
    files = set(PACKAGES[url].namelist())
    index = {}
    for f in sorted(files):
        m = AWS_ICON_RE.fullmatch(f)
        if m:
            # the General- prefix keeps General-Shield from taking the short name of AWS-Shield
            name = m.group(1) or m.group(2) or f"General-{m.group(3)}"
            dark = f.replace("_Light", "_Dark") if m.group(3) else f[:-4] + "_Dark.svg"
            index.setdefault(norm(name), (name, f"{url}#{f}", f"{url}#{dark}" if dark in files else None))
    return index

def aws_sources(query):
    """Exact match on the package's names or an acronym, with the AWS-/Amazon- prefix optional.
    A miss lists every name: a partial match could ship the wrong service's icon."""
    try:
        index = aws_index()
    except (OSError, zipfile.BadZipFile, http.client.HTTPException) as e:
        print(f"AWS  icon package unavailable: {e}")
        return []
    q = norm(AWS_ALIASES.get(norm(query), query))
    hit = index.get(q) or next((v for k, v in index.items() if re.sub(r"^(aws|amazon)", "", k) == q), None)
    if hit:
        return [("aws", hit[1], hit[2])]
    print(f"AWS  {query!r} is not a name in the package. Names: {', '.join(sorted(v[0] for v in index.values()))}")
    return []

@functools.lru_cache(maxsize=1)
def gcp_index():
    """Google's core and category icons as {normalized folder name: (folder name, url)}."""
    index = {}
    for url in GCP_PACKAGES:
        PACKAGES[url] = zipfile.ZipFile(io.BytesIO(get(url)))
        for f in PACKAGES[url].namelist():
            m = GCP_ICON_RE.fullmatch(f)
            if m:
                index[norm(m.group(1))] = (m.group(1), f"{url}#{f}")
    return index

def gcp_sources(query):
    """Exact match on a product or icon name, with the Cloud/Google prefix optional. Most products
    share their category's icon. A miss lists every name, as for AWS."""
    try:
        index = gcp_index()
    except (OSError, zipfile.BadZipFile, http.client.HTTPException) as e:
        print(f"GCP  icon packages unavailable: {e}")
        return []
    products = {p: icon for icon, names in GCP_PRODUCTS.items() for p in names.split(", ")}
    targets = {k: k for k in index} | {norm(p): norm(icon) for p, icon in products.items()}
    q = norm(query)
    hit = index.get(targets.get(q) or next((v for k, v in targets.items() if re.sub(r"^(google|cloud)+", "", k) == q), None))
    if hit:
        return [("gcp", hit[1], None)]
    names = sorted({v[0] for v in index.values()} | set(products), key=str.lower)
    print(f"GCP  {query!r} is not a product or icon name. Names: {', '.join(names)}")
    return []

@functools.lru_cache(maxsize=1)
def azure_index():
    """Microsoft's icon package as {normalized name: (name, url)}. A name can sit in several folders."""
    url = package_from_page(AZURE_ICONS_PAGE, AZURE_PACKAGE_RE)
    index = {}
    for f in sorted(PACKAGES[url].namelist()):
        m = AZURE_ICON_RE.fullmatch(f)
        if m:
            index.setdefault(norm(m.group(1)), (m.group(1).strip(), f"{url}#{f}"))
    return index

def azure_sources(query):
    """Exact match on the package's names or a short form, with the Azure-/Microsoft- prefix and a
    plural s optional. A miss lists every name, as for AWS."""
    try:
        index = azure_index()
    except (OSError, zipfile.BadZipFile, http.client.HTTPException) as e:
        print(f"Azure icon package unavailable: {e}")
        return []
    q = norm(AZURE_ALIASES.get(norm(query), query))
    short = lambda s: re.sub(r"^(azure|microsoft)|s$", "", s)
    hit = index.get(q) or next((v for k, v in index.items() if short(k) == short(q)), None)
    if hit:
        return [("azure", hit[1], None)]
    print(f"Azure {query!r} is not a name in the package. Names: {', '.join(sorted(v[0] for v in index.values()))}")
    return []

def sources(slug):
    # concept icons: slug "lucide/<icon-name>" -> Lucide line icon (browse: lucide.dev/icons)
    if slug.startswith("lucide/"):
        return [("lucide", f"https://cdn.jsdelivr.net/npm/lucide-static/icons/{slug[7:]}.svg", None)]
    if slug.startswith("aws/"):
        return aws_sources(slug[4:])
    if norm(slug) in ("aws", "amazonwebservices"):
        return aws_sources("AWS-Cloud-logo")
    if slug[:4].lower() == "aws-":
        return aws_sources(slug[4:])
    if norm(slug) in ("azure", "microsoftazure"):
        return azure_sources("Azure-A")
    azure = re.fullmatch(r"azure[/-](.+)", slug, re.IGNORECASE)
    if azure:
        return azure_sources(azure.group(1))
    if norm(slug) in ("gcp", "googlecloudplatform"):
        slug = "google-cloud"
    gcp = re.fullmatch(r"(?:gcp/|gcp-|google-cloud-)(.+)", slug, re.IGNORECASE)
    if gcp:
        return gcp_sources(gcp.group(1))
    out = []
    svgl = svgl_lookup(slug)
    if svgl:
        out.append(("svgl", *svgl))
    return out + [
        ("devicon", f"https://raw.githubusercontent.com/devicons/devicon/master/icons/{slug}/{slug}-original.svg", None),
        ("gilbarbara", f"https://cdn.jsdelivr.net/gh/gilbarbara/logos/logos/{slug}-icon.svg", None),
        ("gilbarbara", f"https://cdn.jsdelivr.net/gh/gilbarbara/logos/logos/{slug}.svg", None),
    ]

def main():
    if len(sys.argv) < 3:
        print(__doc__); sys.exit(1)
    outdir = pathlib.Path(sys.argv[1]); outdir.mkdir(parents=True, exist_ok=True)
    mpath = outdir / "manifest.json"
    manifest = json.loads(mpath.read_text()) if mpath.exists() else {}   # merge across runs
    failed = []
    bad_names = False
    for spec in sys.argv[2:]:
        if "=" in spec:
            name, url = spec.split("=", 1); tries = [("explicit", url, None)]
        elif ":" in spec:
            name, slug = spec.split(":", 1); tries = sources(slug)
        else:
            name = spec; tries = sources(name.lower().replace(" ", ""))
        if not NAME_RE.fullmatch(name):
            print(f"SKIP {name!r}: icon name must match [A-Za-z0-9_-]+ (it becomes a filename)")
            bad_names = True; continue
        got = None
        for src, url, dark_url in tries:
            try:
                b = read(url)
            except (urllib.error.URLError, urllib.error.HTTPError, OSError):
                continue
            ext = ext_of(b, url)
            if not ext or len(b) < 50:      # 404 pages, empty bodies
                continue
            got = (src, url, dark_url, b, ext); break
        if not got:
            failed.append(name); continue
        src, url, dark_url, b, ext = got
        if src == "lucide":
            # concept icon, not a brand logo — tinting it to the diagram's slate ink is fine
            b = b.replace(b'stroke="currentColor"', b'stroke="#3d4660"')
            b = b.replace(b'stroke-width="2"', b'stroke-width="1.7"')
        # build.py embeds whichever <name>.* sorts first, so a re-fetch must not leave the old file
        for old in [*outdir.glob(f"{name}.*"), *outdir.glob(f"{name}_dark.*")]:
            if old != mpath:
                old.unlink()
        path = outdir / f"{name}.{ext}"
        path.write_bytes(b)
        entry = {"source": src, "url": url, "file": path.name, "bytes": len(b)}
        if white_fill(b):
            entry["white_fill"] = True
            entry["note"] = ("white fill detected — MAY be a false positive (an inner detail on a "
                             "colored logo). Inspect the SVG; only use a dark tile if the mark is "
                             "actually white overall. Never recolor.")
        if dark_url:
            try:
                db = read(dark_url)
            except (urllib.error.URLError, urllib.error.HTTPError, OSError):
                db = b""
            dext = ext_of(db, dark_url) if len(db) >= 50 else None
            if dext:
                dpath = outdir / f"{name}_dark.{dext}"
                dpath.write_bytes(db); entry["dark_file"] = dpath.name; entry["dark_url"] = dark_url
        manifest[name] = entry
        flag = " [WHITE FILL -> dark tile]" if entry.get("white_fill") else ""
        if "dark_file" in entry:
            flag += f" +{entry['dark_file']} (dark-theme group label)"
        print(f"OK   {name:<16} {src:<12} {len(b):>7}B .{ext}{flag}")
    (outdir / "manifest.json").write_text(json.dumps(manifest, indent=2))
    for name in failed:
        print(f"FAIL {name:<16} no source has it — try name:otherslug, or name=URL with the official "
              f"file (the project's repo root/docs/assets, or its site's /logo.svg, /favicon.svg)")
    sys.exit(1 if failed or bad_names else 0)

if __name__ == "__main__":
    main()
```

## File: skills/stack-diagram/scripts/render_png.py
size: 4416
sha: 109b8ddd2d494b4ee0e99313522e083b57ad0e6d
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/scripts/render_png.py

```text
#!/usr/bin/env python3
"""Render a diagram HTML to PNG with headless Chromium (Playwright).

Usage:
  python3 render_png.py IN.html OUT.png [--selector .canvas] [--scale 2] [--full-page] [--single]

Default behavior:
  - captures BOTH themes: OUT.png (light) and OUT-dark.png (data-theme="dark")
  - strips border-radius on .scroller/.canvas for the screenshot only, so the PNG
    has square corners (the HTML keeps its rounded look)
--selector .canvas   screenshots just the diagram element (default)
--full-page          screenshots the whole page instead
--single             capture only the document as authored (no theme toggling)

Setup if Playwright is missing:
  pip install playwright
  python3 -m playwright install --with-deps chromium
Sandboxed hosts without root, if launch fails on a missing shared library (e.g. libXdamage.so.1):
  apt-get download libxdamage1 && dpkg -x libxdamage1_*.deb /tmp/libs
  (this script auto-adds /tmp/libs/usr/lib/*/ to LD_LIBRARY_PATH when that folder exists)
"""
import glob, os, pathlib, sys

SQUARE_CORNERS = (".scroller{border-radius:0!important;border:none!important}"
                  ".canvas{border-radius:0!important}")

def main():
    args = sys.argv[1:]
    sel, scale, full, single = ".canvas", 2, False, False
    if "--selector" in args:
        i = args.index("--selector"); sel = args[i + 1]; del args[i:i + 2]
    if "--scale" in args:
        i = args.index("--scale"); scale = float(args[i + 1]); del args[i:i + 2]
    if "--full-page" in args:
        args.remove("--full-page"); full = True
    if "--single" in args:
        args.remove("--single"); single = True
    if len(args) != 2:
        print(__doc__); sys.exit(1)
    src, out = pathlib.Path(args[0]).resolve(), pathlib.Path(args[1])

    lib_dirs = glob.glob("/tmp/libs/usr/lib/*")
    if lib_dirs:
        os.environ["LD_LIBRARY_PATH"] = ":".join(
            lib_dirs + [os.environ.get("LD_LIBRARY_PATH", "")]).rstrip(":")

    # Japanese text renders with Chinese-variant glyphs when no JP font is installed
    import subprocess
    try:
        ja = subprocess.run(["fc-list", ":lang=ja"], capture_output=True, text=True).stdout
        if "Sans" not in ja:
            print("WARNING: no Japanese sans font found — JP text will render with Chinese glyph "
                  "variants. Install a Japanese font, e.g. on Debian/Ubuntu: sudo apt-get install fonts-noto-cjk\n"
                  "  or without root:\n"
                  "  apt-get download fonts-noto-cjk && dpkg -x fonts-noto-cjk_*.deb /tmp/fontx\n"
                  "  mkdir -p ~/.fonts && cp /tmp/fontx/usr/share/fonts/opentype/noto/NotoSansCJK-*.ttc ~/.fonts/ && fc-cache -f")
    except FileNotFoundError:
        pass

    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        sys.exit("playwright not installed — see setup notes in this script's docstring")

    def shoot(page, path):
        if full:
            page.screenshot(path=str(path), full_page=True)
        else:
            el = page.query_selector(sel)
            if not el:
                sys.exit(f"selector {sel!r} not found in {src.name}")
            el.screenshot(path=str(path))
        print(f"rendered {path}")

    with sync_playwright() as p:
        # headless Chromium hints glyph advances to whole pixels by default: uneven letter spacing
        b = p.chromium.launch(args=["--no-sandbox", "--font-render-hinting=none"])
        page = b.new_page(viewport={"width": 1200, "height": 1000}, device_scale_factor=scale)
        page.goto(src.as_uri())
        # .scroller clips a canvas wider than the viewport
        width = page.evaluate("document.querySelector('.canvas')?.offsetWidth ?? 0")
        if width + 100 > 1200:
            page.set_viewport_size({"width": width + 100, "height": 1000})
        page.wait_for_timeout(1000)  # let embedded fonts settle
        page.add_style_tag(content=SQUARE_CORNERS)
        if single:
            shoot(page, out)
        else:
            page.evaluate("document.documentElement.removeAttribute('data-theme')")
            page.wait_for_timeout(120)
            shoot(page, out)
            page.evaluate("document.documentElement.setAttribute('data-theme','dark')")
            page.wait_for_timeout(120)
            shoot(page, out.with_name(out.stem + "-dark" + out.suffix))
        b.close()

if __name__ == "__main__":
    main()
```

## File: skills/stack-diagram/scripts/share.py
size: 10321
sha: 6ca4e8773c53740a0fffc446c9096535fede8820
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/scripts/share.py

```text
#!/usr/bin/env python3
"""Share a rendered diagram to the public stack-diagram gallery, or delete a shared one.

Usage:
  python3 share.py submit LIGHT.png DARK.png --title T --stack "A=a,B,C" --name N [--github LOGIN] [--icons DIR]
  python3 share.py delete ID
  python3 share.py list

submit  checks the inputs, solves the gallery's proof-of-work challenge, uploads both PNGs
        with the metadata, and saves the post's delete key to
        ~/.config/stack-diagram/gallery.json (mode 0600). Everything sent becomes public.
        --stack lists the technologies; "Name=key" attaches the logo saved under key in
        DIR/manifest.json, the icons dir fetch_icons.py wrote, passed as --icons DIR.
        Only logos from svgl, gilbarbara/logos and devicon are sent.
delete  deletes a post with the key saved there. If the gallery could confirm the post's
        GitHub login, signing in with that account on the gallery site deletes it too.
list    shows the posts saved there

Limits checked before upload: PNGs up to 1 MB each, 16-4000 px a side and 8 megapixels,
both the same size; title 1-80 chars; 1-24 stack names of up to 40 chars; name 1-40;
--github a valid GitHub login.
STACK_DIAGRAM_GALLERY overrides the gallery base URL.
"""
import hashlib, http.client, json, os, pathlib, re, sys, tempfile, time, urllib.error, urllib.parse, urllib.request, uuid

GALLERY = os.environ.get("STACK_DIAGRAM_GALLERY", "https://stacks.caru.moe").rstrip("/")
UA = {"User-Agent": "Mozilla/5.0 (stack-diagram-skill)"}
STORE = pathlib.Path.home() / ".config" / "stack-diagram" / "gallery.json"
LOGIN_RE = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?")
ICON_PREFIXES = ("https://svgl.app/library/", "https://cdn.jsdelivr.net/gh/gilbarbara/logos/logos/",
                 "https://raw.githubusercontent.com/devicons/devicon/master/icons/")
MAX_SIDE, MAX_PIXELS, MAX_BITS = 4000, 8_000_000, 26   # a 27+ bit challenge outlives its 10 minutes

def usage():
    print(__doc__); sys.exit(1)

def call(method, path, body=None, headers=None):
    """(status, parsed JSON or None); exits with the gallery's error message on 4xx/5xx."""
    req = urllib.request.Request(GALLERY + path, data=body, method=method, headers={**UA, **(headers or {})})
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            status, raw = r.status, r.read()
    except urllib.error.HTTPError as e:
        raw = e.read()
        try:
            msg = json.loads(raw)["error"]
        except (ValueError, KeyError, TypeError):
            msg = raw.decode("utf-8", "replace").strip()[:200] or e.reason
        sys.exit(f"gallery error {e.code}: {msg}")
    except (OSError, http.client.HTTPException) as e:
        sys.exit(f"cannot reach {GALLERY}: {getattr(e, 'reason', e)}")
    try:
        return status, json.loads(raw) if raw else None
    except ValueError:
        sys.exit(f"unexpected non-JSON response from {GALLERY + path}")

def solve(challenge, bits):
    """Smallest n where sha256(f"{challenge}:{n}") has `bits` leading zero bits -> "challenge:n"."""
    target, n = 1 << (256 - bits), 0
    while int.from_bytes(hashlib.sha256(f"{challenge}:{n}".encode()).digest(), "big") >= target:
        n += 1
    return f"{challenge}:{n}"

def multipart(parts):
    """parts: (name, filename or None, str or bytes) -> (body, content-type header)."""
    boundary = uuid.uuid4().hex
    body = b""
    for name, filename, value in parts:
        head = f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"'
        if filename:
            head += f'; filename="{filename}"\r\nContent-Type: image/png'
        body += f"{head}\r\n\r\n".encode() + (value if isinstance(value, bytes) else value.encode()) + b"\r\n"
    return body + f"--{boundary}--\r\n".encode(), f"multipart/form-data; boundary={boundary}"

def read_png(path):
    p = pathlib.Path(path)
    if not p.is_file():
        sys.exit(f"no such file: {path}")
    b = p.read_bytes()
    if not b.startswith(b"\x89PNG\r\n\x1a\n") or b[12:16] != b"IHDR":
        sys.exit(f"not a PNG: {path}")
    if len(b) > 1 << 20:
        sys.exit(f"{path} is {len(b) / (1 << 20):.1f} MB; the gallery takes PNGs up to 1 MB")
    w, h = int.from_bytes(b[16:20], "big"), int.from_bytes(b[20:24], "big")
    if not (16 <= w <= MAX_SIDE and 16 <= h <= MAX_SIDE and w * h <= MAX_PIXELS):
        sys.exit(f"{path} is {w}x{h}; the gallery takes 16-{MAX_SIDE} px a side and up to "
                 f"{MAX_PIXELS // 1_000_000} megapixels. Re-render with a lower --scale")
    return b, (w, h)

def load():
    return json.loads(STORE.read_text()) if STORE.exists() else []

def save(entries):
    """Swaps in a new file (mkstemp makes it 0600), so a crash never loses the keys already saved."""
    STORE.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=STORE.parent, prefix=".gallery-")
    try:
        with os.fdopen(fd, "w") as f:
            json.dump(entries, f, indent=2)
        os.replace(tmp, STORE)
    except BaseException:
        pathlib.Path(tmp).unlink(missing_ok=True)
        raise

def stack_items(spec, icons):
    """--stack "Name=key,Name" -> [{name, icon?, iconDark?}], URLs from the icons dir's manifest."""
    manifest, mpath = None, None
    if icons is not None:
        mpath = pathlib.Path(icons) / "manifest.json"
        try:
            manifest = json.loads(mpath.read_text())
        except (OSError, ValueError) as e:
            sys.exit(f"cannot read {mpath}: {e}")
    items = []
    for part in (p.strip() for p in spec.split(",")):
        if not part:
            continue
        name, has_key, key = (s.strip() for s in part.partition("="))
        if not name:
            sys.exit(f"--stack {part!r} has no name before '='")
        item = {"name": name}
        if has_key:
            if manifest is None:
                sys.exit(f"--stack {part!r} names an icon; pass the icons dir with --icons DIR")
            entry = manifest.get(key) if isinstance(manifest, dict) else None
            if not isinstance(entry, dict) or not entry.get("url"):
                sys.exit(f"no icon {key!r} in {mpath}")
            for field, url in (("icon", entry["url"]), ("iconDark", entry.get("dark_url"))):
                if isinstance(url, str) and url.startswith(ICON_PREFIXES):
                    item[field] = url
        items.append(item)
    return items

def opt(args, flag):
    if flag not in args:
        return None
    i = args.index(flag); v = args[i + 1:i + 2]; del args[i:i + 2]
    return v[0] if v else ""

def submit(args):
    title, stack, name, github, icons = [opt(args, f) for f in ("--title", "--stack", "--name", "--github", "--icons")]
    if len(args) != 2 or None in (title, stack, name) or icons == "":
        usage()
    (light, light_size), (dark, dark_size) = read_png(args[0]), read_png(args[1])
    if light_size != dark_size:
        sys.exit(f"the light and dark PNGs differ in size: {light_size} vs {dark_size}")
    title, name = title.strip(), name.strip()
    items = stack_items(stack, icons)
    names = [item["name"] for item in items]
    for flag, s, hi in (("--title", title, 80), ("--name", name, 40)):
        if not 1 <= len(s) <= hi:
            sys.exit(f"{flag} must be 1-{hi} characters (got {len(s)})")
    if not 1 <= len(names) <= 24:
        sys.exit(f"--stack must list 1-24 comma-separated names (got {len(names)})")
    too_long = [s for s in names if len(s) > 40]
    if too_long:
        sys.exit(f"stack names must be up to 40 characters: {', '.join(too_long)}")
    if github is not None and not LOGIN_RE.fullmatch(github):
        sys.exit(f"--github {github!r} is not a valid GitHub login")
    meta = {"title": title, "stack": items, "name": name}
    if github:
        meta["github"] = github
    entries = load()
    save(entries)   # fail before upload if the key store is not writable

    _, ch = call("GET", "/api/challenge")
    challenge, bits = (ch.get("challenge"), ch.get("bits")) if isinstance(ch, dict) else (None, None)
    if not isinstance(challenge, str) or type(bits) is not int or not 0 <= bits <= MAX_BITS:
        sys.exit(f"unexpected challenge from {GALLERY}: {ch!r}")
    print(f"solving proof of work ({bits} bits)...", flush=True)
    proof = solve(challenge, bits)
    body, ctype = multipart([("meta", None, json.dumps(meta)), ("light", "light.png", light),
                             ("dark", "dark.png", dark)])
    # in a header so the gallery can check it before reading the upload
    _, res = call("POST", "/api/stacks", body, {"Content-Type": ctype, "X-Proof-Of-Work": proof})

    entries.append({"id": res["id"], "url": res["url"], "title": title, "deleteKey": res["deleteKey"],
                    "createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
    save(entries)
    print(f"shared: {res['url']}")
    if res.get("status") == "held":
        print("status: held - it appears in the gallery after a manual review")
    else:
        print(f"status: {res.get('status', 'published')}")
    print(f"delete key saved to {STORE} (delete with: share.py delete {res['id']})")

def delete(post_id):
    entries = load()
    entry = next((e for e in entries if e.get("id") == post_id), None)
    if not entry:
        sys.exit(f"no saved delete key for {post_id!r} in {STORE}.\n"
                 f"If the gallery confirmed the post's GitHub login, sign in with that account on {GALLERY} to delete it.")
    status, _ = call("DELETE", f"/api/stacks/{urllib.parse.quote(post_id, safe='')}",
                     headers={"Authorization": f"Bearer {entry['deleteKey']}"})
    if status != 204:
        sys.exit(f"unexpected response {status} from {GALLERY}; kept the saved key")
    save([e for e in entries if e is not entry])
    print(f"deleted {entry['url']}")

def main():
    args = sys.argv[1:]
    cmd = args.pop(0) if args else None
    if cmd == "submit":
        submit(args)
    elif cmd == "delete" and len(args) == 1:
        delete(args[0])
    elif cmd == "list" and not args:
        entries = load()
        if not entries:
            print(f"no shared diagrams saved in {STORE}")
        for e in entries:
            print(f"{e['id']}  {e['createdAt'][:10]}  {e['title']}  {e['url']}")
    else:
        usage()

if __name__ == "__main__":
    main()
```

## File: skills/stack-diagram/references/geometry.md
size: 4935
sha: 655800c9c37b970bf5d9852a87b4d85199739638
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/references/geometry.md

````text
# Geometry — coordinates, spacing, and collision rules

The canvas is a fixed-size absolutely-positioned coordinate system (default 1000px wide; height as needed). Nodes are `position:absolute` divs; edges live in one SVG overlay with the same viewBox as the canvas size. Because everything is hand-placed, the collision rules below are what keep the result clean.

## The node box

```
left = centerX - 66          (node div is 132 wide, text centered)
tile: 60x60, top of node div (tile spans centerX±30, topY..topY+60)
name: ~13px display font at topY+68
role: ~10px mono at topY+86
node box bottom ≈ topY + 100
```

So a node occupies roughly **132w × 100h**. All anchor math uses these numbers.

## Spacing constants

- Horizontal: ≥140px between node centers on the same row (labels are 132 wide — closer and they touch)
- Vertical: 140px between tile tops of consecutive rows (leaves ~40px clear between one row's box bottom and the next row's tile)
- Groups, left/right: edges 24px outside the outermost node *boxes*, i.e. `leftmost centerX − 90` and `rightmost centerX + 90`. Measure from the 132px box, not the 60px tile — a name wider than its tile otherwise crowds one side, and every group gets the same, symmetric padding
- Groups, top/bottom: top edge = first tile top − 60 (the group label lives in that band); **bottom edge ≥ last tile top + 150** so node text stays inside the box
- Group label: inside the top-left corner (`top:12px; left:12px`, no background), so the border stays unbroken. Keep edges out of that corner.
- Nested group: inset ≥24px from the parent on the sides and bottom, and start ≥48px below the parent's top edge (under the parent's label)

## Edge anchoring — the rule that prevents text collisions

**A node is its tile plus the text below it.** Treat `(centerX±66, topY..topY+100)` as the connection boundary.

- **Horizontal edges** (same row): connect tile side to tile side at tile mid-height, `y = topY+30`, from `x = centerX+30+4` to `x = targetCenterX-30-4`. This is safe because there's no text beside tiles.
- **Vertical edges** (node above node): connect **box bottom to tile top** — from `y = topY_upper + 100` down to `y = topY_lower - 4` (or upward, reversed). Never draw from tile bottom (`topY+60`) through the label text.
- **Elbow edges** (different row AND column): run the vertical segment in an x-corridor free of node boxes; run the horizontal segment in the clear band between rows (`topY_lower - 40 .. topY_lower - 10`). Crossing another *edge* is normal diagram practice; crossing a *node box* is not.
- Leave a 4–6px gap between line end and the boundary; arrowheads (`marker-end`) supply the touch.

Two layers of defense are built into the template — keep them:
1. Edge labels use `paint-order: stroke` with a paper-colored stroke (halo), so lines under them stay readable.
2. Node name/role text is wrapped in `<span>`s with paper background, so an unavoidable crossing line disappears behind the text instead of striking through it.

## Edge classes and labels

```html
<path class="e" d="M148 150 H306" marker-end="url(#ar)"/>        accent solid — primary flow
<path class="n" d="M487 226 V344" marker-end="url(#ar)"/>        neutral solid — internal
<path class="n dash" d="M318 380 H208" marker-end="url(#ar)"/>   dashed — dev/deploy
<text class="el a" x="227" y="142" text-anchor="middle">HTTPS</text>
```

The arrowhead marker is `#ar`. It fills with `context-stroke`, so it takes the color of whichever line uses it, in both themes — don't add per-color markers. AWS diagrams use `#open`, the same way.

- Horizontal edge label: centered above the line, `y = lineY - 8`
- Vertical edge label: right of the line, `x = lineX + 10`, vertically mid-segment
- Use `.el.a` (accent) for labels on accent edges, `.el` for the rest
- Keep labels to 1–2 short words; the role text under nodes carries the detail

## Worked example (the template's sample)

Canvas 1000×540. Row 1 tiles at top=120: User(cx 112), App(cx 342), API(cx 487). Row 2 tiles at top=350: Tool One(cx 172), Tool Two(cx 354). Vendor group around App and API `left:252 top:60 w:325 h:210` (342−90 → 487+90; top 120−60; bottom 270 = 120+150); tooling group `left:82 top:290 w:362 h:210` (172−90 → 354+90; top 350−60; bottom 500 = 350+150).

```
user→app        M148 150 H306      (horizontal, tile-mid: 112+30+6 → 342-30-6)   label "HTTPS" at (227, 142)
app→api         M376 150 H451      (horizontal, tile-mid)
tool2→tool1     M318 380 H208      (dashed, tile-mid of row 2: 354-30-6 → 172+30+6) label "pre-commit" at (263, 372)
```

A vertical edge from API down to a node at cx 487 in row 2 would be `M487 226 V344` (box bottom 120+100+6 → tile top 350-6).

Sanity-check your plan by listing every vertical/elbow segment and asking: which node boxes does this x-corridor pass through between y1 and y2? The answer must be none.
````

## File: skills/stack-diagram/references/tsx.md
size: 2328
sha: 89868e216fe6ec973ab827c5888ee1c64a1ac335
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/references/tsx.md

````text
# TSX output

Convert the built HTML into a single self-contained React component the user can drop into their codebase. The geometry and content are identical to the HTML; only the packaging changes.

## Shape

```tsx
// ArchDiagram.tsx — no props required, no dependencies
const ICONS = {
  hono: "data:image/svg+xml;base64,...",
  // one entry per node, copied from the built HTML's data URIs
} as const;

const T = {
  paper: "oklch(98.5% 0.004 250)",
  ink: "oklch(24% 0.02 258)",
  accent: "oklch(58% 0.2 256)",
  rule2: "oklch(83% 0.011 255)",
  // ... mirror every :root token: drop the "--color-" prefix and camelCase the rest
  // (--color-paper-2 → paper2, --color-rule-2 → rule2, --color-g1-ink → g1Ink, --color-accent-soft → accentSoft)
} as const;

export default function ArchDiagram() {
  return (
    <div style={{ overflowX: "auto", border: `1px solid ${T.rule2}`, borderRadius: 10 }}>
      <div style={{ position: "relative", width: 1000, height: 540, background: T.paper, /* grid via backgroundImage */ }}>
        {/* groups, svg edges, nodes — same coordinates as the HTML */}
      </div>
    </div>
  );
}
```

## Rules

- **Self-contained**: data-URI icons in a const, tokens in a const, inline `style` objects. No CSS files, no Tailwind assumptions, no imports beyond React. It must compile in any TSX project as-is.
- **Do not embed the woff2 fonts.** A component ships inside an app that has its own font pipeline. Use the fallback stacks (`ui-monospace...`, `system-ui...`) and note in a leading comment that loading "Geist" / "JetBrains Mono" restores the reference look.
- Convert CSS classes to small local components or mapped style objects (`<Node cx={342} top={190} icon="tanstack" name="TanStack Start" role="frontend / ssr" dark? />`) — this keeps the file readable and makes the user's later edits easy, which is the point of asking for TSX.
- The SVG edge overlay transfers as-is (JSX-ify attribute names: `stroke-width` → `strokeWidth`, `marker-end` → `markerEnd`, `paint-order` → `paintOrder`).
- Hover states from the template (accent border on tile) become `onMouseEnter`/`Leave` state or are simply dropped — ask only if the user cares.
- Deliver to the workspace as `<Name>Diagram.tsx`; don't publish TSX as an artifact (it's code for their repo, not a page).
````

## File: skills/stack-diagram/assets/fonts/Geist.woff2
size: 29400
sha: 991445d78ae619a7378ff219385588c7811c88ab
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/fonts/Geist.woff2

Content is not inlined in the catalog. Read the upstream file: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/fonts/Geist.woff2

## File: skills/stack-diagram/assets/fonts/JetBrainsMono.woff2
size: 31340
sha: 2ca6ac605954ee884442db8ea1d61093000bcc29
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/fonts/JetBrainsMono.woff2

Content is not inlined in the catalog. Read the upstream file: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/fonts/JetBrainsMono.woff2

## File: skills/stack-diagram/assets/fonts/OFL-Geist.txt
size: 4383
sha: 04e95fc550855eb44416b9db1f6722f5cb7342db
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/fonts/OFL-Geist.txt

```text
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org

-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded, 
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
```

## File: skills/stack-diagram/assets/fonts/OFL-JetBrainsMono.txt
size: 4399
sha: 66cad591a1525e66ed4475b1c01d97f9d601ae0a
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/fonts/OFL-JetBrainsMono.txt

```text
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org

-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) and the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
```

## File: skills/stack-diagram/assets/generic/browser.svg
size: 242
sha: 1667840dca5f65c58c1a522983766c75b1df37eb
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/generic/browser.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#3d4660" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4" width="19" height="13" rx="1.5"/><path d="M8 21h8M12 17v4"/></svg>
```

## File: skills/stack-diagram/assets/generic/device.svg
size: 285
sha: 6fb07a9b362252a5ee36ec96a06cecfbbb6cada0
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/generic/device.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#3d4660" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="13" height="10" rx="1.5"/><path d="M6 19h7"/><rect x="17.5" y="8" width="4" height="11" rx="1.2"/></svg>
```

## File: skills/stack-diagram/assets/generic/internet.svg
size: 293
sha: d90f1302fdb2b8237f212b2dd979840e3c35321f
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/generic/internet.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#3d4660" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.8 2.6 4.2 5.6 4.2 9S14.8 18.4 12 21c-2.8-2.6-4.2-5.6-4.2-9S9.2 5.6 12 3z"/></svg>
```

## File: skills/stack-diagram/assets/generic/server.svg
size: 319
sha: 9c600a44d9d95aa499de2632a5431e8bd6957824
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/generic/server.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#3d4660" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="7" rx="1.5"/><rect x="3.5" y="13" width="17" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01M11 7.5h3M11 16.5h3"/></svg>
```

## File: skills/stack-diagram/assets/generic/user.svg
size: 255
sha: 1117d7c9ac91e6fe7c096c6bc739af5eae95986b
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/generic/user.svg

```text
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#3d4660" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20.5c1.4-3.6 4.2-5.4 7.5-5.4s6.1 1.8 7.5 5.4"/></svg>
```

## File: skills/stack-diagram/assets/template.html
size: 15292
sha: f15cee46ea0b7f19fcb3f5746b8d532a9d17fcc3
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/assets/template.html

```text
<!DOCTYPE html>
<html lang="en"><!-- set lang to the label language, e.g. "ja" for Japanese labels -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><!-- TITLE --></title>
<style>
/* engineered-paper theme (cool near-white, one accent, mono labels)
 * All colors/fonts flow through the :root tokens — tweak tokens, not scattered values. */
:root {
  color-scheme: light;
  --color-paper:   oklch(98.5% 0.004 250);
  --color-paper-2: oklch(96.5% 0.005 252);
  --color-rule:    oklch(93% 0.007 255);    /* graph-paper grid */
  --color-rule-2:  oklch(83% 0.011 255);    /* hairline borders */
  --color-muted:   oklch(56% 0.013 255);
  --color-neutral: oklch(46% 0.015 255);    /* neutral edges */
  --color-ink-2:   oklch(34% 0.018 257);
  --color-ink:     oklch(24% 0.020 258);
  --color-accent:  oklch(58% 0.20 256);     /* primary-flow edges, one signal color */
  --color-graphite:oklch(24% 0.018 260);    /* dark tile for white/light logos */
  /* group palette A — warm vendor (e.g. Cloudflare/AWS orange). Retune hue per vendor. */
  --color-g1:      oklch(68% 0.15 55);
  --color-g1-soft: oklch(78% 0.12 55);
  --color-g1-ink:  oklch(52% 0.14 50);
  --color-g1-tint: oklch(96.5% 0.015 60 / .5);
  /* group palette B — accent-family (repo/tooling groups) */
  --color-accent-soft: oklch(58% 0.20 256 / .45);
  --color-accent-tint: oklch(97% 0.012 256 / .5);
  /* JP glyphs: name JP faces explicitly — bare sans-serif fallback on Linux picks Chinese-variant CJK */
  --font-display: "Geist", "Hiragino Sans", "Yu Gothic UI", "Noto Sans CJK JP", "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
  --font-body: "Hiragino Sans", "Yu Gothic UI", "Noto Sans CJK JP", "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
  --space-xs: 4px; --space-sm: 8px; --space-md: 16px; --space-lg: 24px; --space-xl: 40px; --space-2xl: 64px;
  --radius-card: 10px; --radius-tile: 12px;
  /* translucent veil behind node label text: matches paper on plain canvas, blends into
     group tints instead of punching an off-color rectangle through them */
  --color-veil: oklch(98.5% 0.004 250 / .78);
}
/* Dark variant: set data-theme="dark" on <html>. Tiles stay white — brand logos are drawn
   for light grounds. Everything (arrowheads via context-stroke, legend via border-color)
   follows the tokens, so toggling the attribute is all it takes. */
[data-theme="dark"] {
  color-scheme: dark;
  --color-paper:   oklch(18% 0.012 255);
  --color-paper-2: oklch(22% 0.014 255);
  --color-rule:    oklch(24% 0.013 255);
  --color-rule-2:  oklch(38% 0.018 255);
  --color-muted:   oklch(65% 0.014 255);
  --color-neutral: oklch(72% 0.012 255);
  --color-ink-2:   oklch(85% 0.010 255);
  --color-ink:     oklch(94% 0.008 255);
  --color-accent:  oklch(70% 0.16 256);
  --color-graphite:oklch(31% 0.020 260);
  --color-g1:      oklch(70% 0.14 55);
  --color-g1-soft: oklch(58% 0.11 55);
  --color-g1-ink:  oklch(78% 0.12 55);
  --color-g1-tint: oklch(26% 0.030 60 / .40);
  --color-accent-soft: oklch(70% 0.16 256 / .5);
  --color-accent-tint: oklch(28% 0.045 256 / .35);
  --color-veil: oklch(18% 0.012 255 / .78);
}
@font-face { font-family:"Geist"; font-style:normal; font-weight:100 900; font-display:swap;
  src:url(data:font/woff2;base64,%%FONT_DISPLAY%%) format("woff2"); }
@font-face { font-family:"JetBrains Mono"; font-style:normal; font-weight:100 800; font-display:swap;
  src:url(data:font/woff2;base64,%%FONT_JBM%%) format("woff2"); }
/* JetBrains Mono reads cramped at 9.5–11px; open every mono label slightly */
.kicker, .node .role, .el, .legend, .foot { letter-spacing: 0.04em; }
* { margin:0; padding:0; box-sizing:border-box; }
html, body { overflow-x: clip; }
body { background: var(--color-paper); color: var(--color-ink); font-family: var(--font-body); padding: var(--space-xl) var(--space-lg) var(--space-2xl); }
.page { width: fit-content; max-width: 100%; margin: 0 auto; }

.head { display:grid; grid-template-columns: 1fr auto; gap: var(--space-lg); align-items:end;
  padding-bottom: var(--space-md); border-bottom: 1px solid var(--color-rule-2); margin-bottom: var(--space-lg); }
.kicker { font-family: var(--font-mono); font-size: 11px;
  color: var(--color-accent); margin-bottom: var(--space-sm); }
h1 { font-family: var(--font-display); font-weight: 700; font-size: clamp(22px, 4vw, 30px);
  letter-spacing: -0.005em; line-height: 1.2; }
.lede { max-width: 300px; font-size: 12px; line-height: 1.7; color: var(--color-neutral); padding-bottom: 2px; }
@media (max-width: 680px) { .head { grid-template-columns: 1fr; align-items:start; } .lede { max-width:none; } }

.scroller { overflow-x: auto; border: 1px solid var(--color-rule-2); border-radius: var(--radius-card); }
/* CANVAS SIZE: set width/height here AND on svg.edges AND its viewBox — keep the three in sync */
.canvas { position: relative; width: 1000px; height: 540px; background: var(--color-paper);
  background-image:
    linear-gradient(var(--color-rule) 1px, transparent 1px),
    linear-gradient(90deg, var(--color-rule) 1px, transparent 1px);
  background-size: 24px 24px; background-position: -1px -1px; }

.group { position:absolute; border-radius: var(--radius-tile); }
/* group labels are names like node names (display font), written in the brand's own case.
   They sit inside the top-left corner, so the border stays unbroken and needs no mask */
.g-label { position:absolute; top:12px; left:12px; display:flex; align-items:center; gap:6px;
  font-family: var(--font-display); font-size: 12px; font-weight: 600;
  color: var(--color-neutral); white-space: nowrap; }
.g-label img { width: 14px; height: 14px; }
/* group labels sit on the group's tint, not on a white tile: in the dark theme swap in the brand's own
   dark-theme logo (lt/dk pair), or give a mark with black parts a white plate */
.g-label img.dk, .tile img.dk { display:none; }
[data-theme="dark"] .g-label img.lt, [data-theme="dark"] .tile img.lt { display:none; }
[data-theme="dark"] .g-label img.dk, [data-theme="dark"] .tile img.dk { display:block; }
[data-theme="dark"] .g-label img.plate { background:#fff; border-radius:3px; padding:2px; width:18px; height:18px; }
.group.ga { border:1.5px solid var(--color-g1); background: var(--color-g1-tint); }
.group.ga-nested { border:1.5px dashed var(--color-g1-soft); }
.group.gb { border:1.5px solid var(--color-accent-soft); background: var(--color-accent-tint); }
.group.ga > .g-label, .group.ga-nested > .g-label { color: var(--color-g1-ink); }
.group.gb > .g-label { color: var(--color-accent); }
.group.aws { border:1.5px solid var(--color-ink); border-radius:0; }
.group.aws > .g-label { top:-1.5px; left:-1.5px; gap:8px; color: var(--color-ink); }
.group.aws > .g-label img { width:30px; height:30px; }
.group.aws.az > .g-label { top:8px; left:12px; }
.group.aws.region, .group.aws.az { border-color:#00A4A6; border-style:dashed; }
.group.aws.vpc { border-color:#8C4FFF; }
.group.aws.private { border-color:#00A4A6; }
.group.aws.public { border-color:#7AA116; }
.group.gcp { border:1.5px solid #4285F4; border-radius:6px; }
.group.azure { border:1.5px solid #0078D4; border-radius:6px; }
.group.gcp > .g-label, .group.azure > .g-label { gap:8px; color: var(--color-ink); }
.group.gcp > .g-label img, .group.azure > .g-label img { width:20px; height:20px; }
.group.gcp.region, .group.azure.region { border-color: var(--color-rule-2); background: var(--color-paper-2); }
.group.gcp.vpc { border:1.5px dashed var(--color-ink-2); }
.group.gcp.subnet { border:1px dashed var(--color-neutral); }
.group.gcp.zone { border-color:#34A853; }
.group.azure.rg { border:1.5px dashed #0096D7; }
.group.azure.vnet { border:1.5px solid #A4CBFB; }
.group.azure.subnet { border:1px solid #A4CBFB; }
.group.azure.zone { border-color:#FFD965; }

.node { position:absolute; width:132px; text-align:center; }
.tile { width:60px; height:60px; margin:0 auto; border-radius: var(--radius-tile);
  background:#fff; border:1px solid var(--color-rule-2); display:grid; place-items:center;
  box-shadow: 0 1px 2px oklch(24% 0.02 258 / 0.05); transition: border-color .15s cubic-bezier(0.16,1,0.3,1); }
.node:hover .tile { border-color: var(--color-accent); }
.tile img { width:34px; height:34px; object-fit:contain; }
.tile.wide img { width:44px; }                       /* wide-aspect logos */
.tile.dark { background: var(--color-graphite); border-color: var(--color-graphite); }  /* white/light logos */
.tile.bare { background:none; border:none; box-shadow:none; }
.tile.bare img { width:60px; height:60px; }
.tile.bare.res img { width:44px; height:44px; }
.node .name { font-family: var(--font-display); font-weight:600; font-size:13px; margin-top: var(--space-sm); letter-spacing:0; }
.node .role { font-family: var(--font-mono); font-size:9.5px; color: var(--color-muted); margin-top:2px; }
/* veil behind label spans: crossing edges/grid fade behind text; translucent so it
   blends into group tints (a solid paper rectangle looks pasted-on inside tinted groups) */
.node .name span, .node .role span { background: var(--color-veil); backdrop-filter: blur(1.5px);
  padding: 0 var(--space-xs); border-radius: 3px; -webkit-box-decoration-break: clone; box-decoration-break: clone; }

svg.edges { position:absolute; inset:0; width:1000px; height:540px; pointer-events:none; }
.e  { stroke: var(--color-accent); stroke-width:1.6; fill:none; }   /* primary flow */
.n  { stroke: var(--color-neutral); stroke-width:1.4; fill:none; }  /* internal */
path.aws { stroke: var(--color-ink); stroke-width:1.6; fill:none; }
.dash { stroke-dasharray:4 5; }                                     /* dev / deploy */
.el { font-family: var(--font-mono); font-size:10.5px; fill: var(--color-neutral);
  paint-order: stroke; stroke: var(--color-paper); stroke-width:4px; stroke-linejoin:round; }  /* halo */
.el.a { fill: var(--color-accent); }

.legend { display:flex; gap: var(--space-lg); align-items:center; margin-top: var(--space-md);
  font-family: var(--font-mono); font-size:10px; color: var(--color-muted); flex-wrap:wrap; }
.legend .sw { display:inline-block; width:30px; vertical-align:middle; margin-right:6px;
  border-top:1.6px solid var(--color-accent); }
.legend .sw.n { border-color: var(--color-neutral); border-top-width:1.4px; }
.legend .sw.dash { border-top-style: dashed; }

.foot { margin-top: var(--space-xl); padding-top: var(--space-md); border-top: 1px solid var(--color-rule-2);
  display:flex; justify-content:space-between; gap: var(--space-md); flex-wrap:wrap;
  font-family: var(--font-mono); font-size:10px; color: var(--color-muted); }
.foot a { color: inherit; text-decoration-color: var(--color-rule-2); text-underline-offset: 2px; }
.foot a:hover { color: var(--color-accent); }
</style>
</head>
<body>
<div class="page">

  <header class="head">
    <div>
      <div class="kicker"><!-- KICKER: e.g. stack-diagram / author / 2026-08 --></div>
      <h1><!-- TITLE --></h1>
    </div>
    <p class="lede"><!-- LEDE: 1-2 short sentences, or delete this <p> --></p>
  </header>

  <div class="scroller">
  <div class="canvas">

    <!-- GROUPS ============================================================ -->
    <!-- sample: a warm vendor group and a tooling group (for a nested group use class "ga-nested").
         group-label icon with a dark variant (fetch_icons saved <name>_dark):
           <img class="lt" src="%%ICON_<name>%%" alt=""><img class="dk" src="%%ICON_<name>_dark%%" alt="">
         no dark variant but the mark has black parts: <img class="plate" ...>
         AWS groups: "group aws" (AWS Cloud), or add region | az | vpc | private | public
         Google Cloud groups: "group gcp", or add region | vpc | subnet | zone
         Azure groups: "group azure", or add rg | region | vnet | subnet | zone
         geometry rules: references/geometry.md -->
    <div class="group ga" style="left:252px; top:60px; width:325px; height:210px;">
      <div class="g-label"><img src="%%ICON_vendor%%" alt="">Vendor Cloud</div>
    </div>
    <div class="group gb" style="left:82px; top:290px; width:362px; height:210px;">
      <div class="g-label"><img src="%%ICON_tool1%%" alt="">Tooling</div>
    </div>

    <!-- EDGES ============================================================= -->
    <svg class="edges" viewBox="0 0 1000 540" aria-hidden="true">
      <defs>
        <!-- context-stroke inherits the line's color, so theme switches (incl. dark)
             recolor arrowheads automatically. #open is for AWS diagrams -->
        <marker id="ar" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
          <path d="M0 0 L10 5 L0 10 z" fill="context-stroke"/>
        </marker>
        <marker id="open" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
          <path d="M1 1 L9 5 L1 9" fill="none" stroke="context-stroke" stroke-width="1.5"/>
        </marker>
      </defs>
      <path class="e" d="M148 150 H306" marker-end="url(#ar)"/>
      <text class="el a" x="227" y="142" text-anchor="middle">HTTPS</text>
      <path class="e" d="M376 150 H451" marker-end="url(#ar)"/>
      <path class="n dash" d="M318 380 H208" marker-end="url(#ar)"/>
      <text class="el" x="263" y="372" text-anchor="middle">pre-commit</text>
    </svg>

    <!-- NODES ============================================================= -->
    <!-- node: left = centerX-66; tile classes: (default) | wide | dark | bare | bare res -->
    <div class="node" style="left:46px;top:120px">
      <div class="tile"><img src="%%ICON_client%%" alt=""></div>
      <div class="name"><span>User</span></div><div class="role"><span>browser</span></div>
    </div>
    <div class="node" style="left:276px;top:120px">
      <div class="tile"><img src="%%ICON_app%%" alt=""></div>
      <div class="name"><span>App</span></div><div class="role"><span>frontend</span></div>
    </div>
    <div class="node" style="left:421px;top:120px">
      <div class="tile"><img src="%%ICON_api%%" alt=""></div>
      <div class="name"><span>API</span></div><div class="role"><span>http server</span></div>
    </div>
    <div class="node" style="left:106px;top:350px">
      <div class="tile"><img src="%%ICON_tool1%%" alt=""></div>
      <div class="name"><span>Tool One</span></div><div class="role"><span>build system</span></div>
    </div>
    <div class="node" style="left:288px;top:350px">
      <div class="tile dark"><img src="%%ICON_tool2%%" alt=""></div>
      <div class="name"><span>Tool Two</span></div><div class="role"><span>git hooks</span></div>
    </div>

  </div>
  </div>

  <div class="legend">
    <span><span class="sw"></span>request path</span>
    <span><span class="sw n"></span>internal</span>
    <span><span class="sw n dash"></span>dev / deploy</span>
  </div>

  <div class="foot">
    <span><!-- FOOT-LEFT: author / one-liner --></span>
    <span><!-- FOOT-RIGHT: icon sources actually used, from manifest.json, e.g. "icons: svgl / devicon / Lucide" --> · made with <a href="https://github.com/caru-ini/stack-diagram" target="_blank" rel="noopener">stack-diagram</a></span>
  </div>

</div>
</body>
</html>
```

## File: skills/stack-diagram/LICENSE
size: 1215
sha: 6e7a068284fdd4e857950d2d8ecadee5bf445d13
upstream: https://github.com/caru-ini/stack-diagram/blob/main/skills/stack-diagram/LICENSE

```text
MIT License

Copyright (c) 2026 caru-ini

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

---

The fonts in assets/fonts/ are not covered by this license; they are licensed
under the SIL Open Font License 1.1 (see assets/fonts/OFL-*.txt).
```
