⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
stack-diagram
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.
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
Not yet listed on ClawHub or SkillsMP
// README
stack-diagram
Describe your stack in a sentence. Get an architecture diagram with official logos.
Diagram our stack: Next.js and a Hono API on Vercel. Postgres on Neon, auth with Clerk, payments with Stripe. GitHub Actions deploys.
npx skills add caru-ini/stack-diagram
Works with Claude Code, Codex, Cursor and other agents that support skills. In the Claude apps, upload stack-diagram.zip from Releases as a skill.
More examples
Light and dark
Every diagram is rendered in both themes. Tiles stay white so logos keep their own colors, and group labels switch to the brand's dark-theme logo.
Share it as an artifact
The diagram is one HTML file with its fonts and logos inlined, so it renders anywhere without a network. Publish it as a Claude artifact and share the link, or drop it into a repo or wiki. The PNGs and the React component are rendered from the same file.
Share it to the gallery
After drawing, the agent offers once to post the diagram to the public gallery at https://stacks.caru.moe. It shows what will be public (the light and dark PNGs, an English title, the tech stack and a display name) and posts only after you say yes twice. Your prompt is never sent. A GitHub login is optional and never shown; it only lets you delete the post later. The site's source is in site/.
What you get
- Official logos, fetched for you. From svgl, then devicon, then gilbarbara/logos, or the brand's own repository. AWS, Google Cloud and Azure icons come from each vendor's own package: AWS, Google Cloud, Azure. Never recolored or redrawn.
- No arrows through text. Lines are routed around labels, and the agent renders the diagram and checks it before handing it over.
- The format you need. A self-contained HTML file (default), PNG, a dependency-free React component, or an Artifact.
Requires Python 3.9+ and network access for the logos. PNG export uses Playwright with Chromium.
Star history
Licenses and trademarks
- Code and docs: MIT — see
LICENSE. - Bundled fonts: SIL Open Font License 1.1 — see
skills/stack-diagram/assets/fonts/. - Logos are not bundled with the skill. They are downloaded at build time from svgl, devicon (MIT), gilbarbara/logos (CC0), AWS Architecture Icons, Google Cloud product icons, Azure icons and Lucide (ISC), or from the brand's own site o
// HOW IT'S BUILT
// FILES
19 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
skills/stack-diagram/SKILL.md20 KB040f77124f9fupstream ↗
--- 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.skills/stack-diagram/assets/fonts/README.md448 Bef6544c71a44upstream ↗
# 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.skills/stack-diagram/scripts/build.py1.8 KB1041769738edupstream ↗
#!/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()skills/stack-diagram/scripts/fetch_icons.py22 KB11024d8e4f63upstream ↗
#!/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()skills/stack-diagram/scripts/render_png.py4.3 KB109b8ddd2d49upstream ↗
#!/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()skills/stack-diagram/scripts/share.py10 KB6ca4e8773c53upstream ↗
#!/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()skills/stack-diagram/references/geometry.md4.8 KB655800c9c37bupstream ↗
# 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.skills/stack-diagram/references/tsx.md2.3 KB89868e216fe6upstream ↗
# 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).skills/stack-diagram/assets/fonts/Geist.woff229 KB991445d78ae6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/stack-diagram/assets/fonts/JetBrainsMono.woff231 KB2ca6ac605954upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/stack-diagram/assets/fonts/OFL-Geist.txt4.3 KB04e95fc55085upstream ↗
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.skills/stack-diagram/assets/fonts/OFL-JetBrainsMono.txt4.3 KB66cad591a152upstream ↗
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.skills/stack-diagram/assets/generic/browser.svg242 B1667840dca5fupstream ↗
<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>skills/stack-diagram/assets/generic/device.svg285 B6fb07a9b3622upstream ↗
<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>skills/stack-diagram/assets/generic/internet.svg293 Bd90f1302fdb2upstream ↗
<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>skills/stack-diagram/assets/generic/server.svg319 B9c600a44d9d9upstream ↗
<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>skills/stack-diagram/assets/generic/user.svg255 B1117d7c9ac91upstream ↗
<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>skills/stack-diagram/assets/template.html15 KBf15cee46ea0bupstream ↗
<!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>skills/stack-diagram/LICENSE1.2 KB6e7a068284fdupstream ↗
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).
Plain-text copy, no login: /skill-bundles/caru-ini-stack-diagram-skills-stack-diagram.md