agentleFS
LizardLiangLizardLiang/interactive-diagram/SKILL.md

interactive-diagram

Skill2 starsChanged 2 months agoMITView on GitHub

Build a beautiful, interactive technical diagram as a single self-contained HTML file — pure HTML, CSS, and vanilla JS, no frameworks, no build step, no CDN. Use whenever the user wants to create, draw, visualize, or explore a system/architecture, sequence, flow, data-flow, component, entity-relationship, or timeline/roadmap diagram in the browser, even if they don't say "interactive" or "HTML." Timeline triggers include "timeline", "時間軸", "roadmap", "藍圖 / 路線圖", "milestones", "里程碑", "release history", "chronology", "project schedule" — anything asking to lay dated events out in order. Also covers a static, slide-ready "platform architecture" poster (capability columns + provider bar) exported as PNG/SVG for slides/PPT — trigger on "platform architecture", "平台架構", "platform diagram", "平台圖", "infographic", "投影片/PPT/deck/pitch", or any infographic-style architecture picture. When a request could be either an explorable diagram or a slide poster and gives no clear cue, ask which before building.

1 other project uses a version of this.

Adopted by

Most recently taken up by

  • Other versions1 project uses a version of this.

    Word for word · 0

    None found.

    Edited · 0

    None found.

    Wrote their own version · 1

    1. kv0906/cc-skills★ 42same name

    How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.

The file

Show its rules and text

We split this skill into the rules it gives an agent

26 rules. About 0 are common, 13 are distinctive, and 13 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈13

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 13

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Run this check first, before copying any template.

    line 21
  2. Very specific:

    Platform poster: when the request carries any *slide / marketing / poster* signal: platform architecture, 平台架構, platform diagram / 平台圖, …

    line 23
  3. Very specific:

    Interactive diagram: when the request carries any *explore / engineering* signal: interactive, explore, pan / zoom / drag / clickable, …

    line 25
  4. Distinctive:

    Pure HTML + CSS + vanilla JS only: No React, Vue, Svelte, no build step, no npm, no bundler.

    line 40
  5. Very specific:

    One file: All CSS in a single <style> tag, all JS in a single <script> tag, all markup in one HTML document.

    line 43
  6. Very specific:

    When clause 2 applies, ask with AskUserQuestion before drafting any tab:

    line 59
  7. Distinctive:

    If forced to pick without asking, default to My code + library entry points — it is the middle ground the Serilog session never found on …

    line 66
  8. Very specific:

    Do not hand-write the renderer from a blank file.

    line 79
  9. Very specific:

    scale: Default "even" unless the *gaps themselves* carry meaning.

    line 465
  10. Distinctive:

    Accepted changes land in the HTML, never chat-only: If a reviewer approves wording, or a re-placement, in conversation, write it into the …

    line 496
  11. Distinctive:

    Blocks that always travel together: If two blocks are never mentioned apart and nothing routes between them separately, they are one block.

    line 513
  12. Distinctive:

    Edges the layout already implies: If a block sits inside a zone whose caption already states the relationship, the line adds ink and no …

    line 516
  13. Distinctive:

    If the user accepts, spawn one subagent, hand it the drafted config object, and have it walk this ladder top-down — stopping as soon as the …

    line 557
  14. Distinctive:

    Never cut ad hoc; the order is the point.

    line 572
  15. Distinctive:

    Density holds: No tab is a field of ungrouped blocks — past about a dozen blocks they sit in containers — and if you offered the …

    line 598
  16. Distinctive:

    Every node's panel has real content, the chain highlight follows the true flow, and the legend matches the node types you used.

    line 602
  17. Distinctive:

    Verify PNG in original, 16:9, and 4:3 modes.

    line 606
  18. Distinctive:

    If the audit can't clear a stubborn tab on its own, the ⚑ Audit overlap → auto-fix button restores the tidy built layout.

    line 611
  19. Very specific:

    New connector: add a function to the CONNECTORS registry keyed by name.

    line 708
  20. Distinctive:

    Before handing over, open the file in a browser and confirm:

    line 713
  21. Very specific:

    Every file under examples/ is a self-contained snapshot that

    line 726
  22. Very specific:

    This is a mandatory gate, not an on-request service: Before editing ANY pre-existing diagram file — whether the user asked for an upgrade …

    line 745
  23. Very specific:

    Run with --check first: it prints fileVer -> targetVer and makes no changes.

    line 758
  24. Very specific:

    Config-only contract: only the config object and <title> are the user's authored content — everything else is the template.

    line 770
  25. Very specific:

    If the script aborts (ambiguous or missing config marker), it writes nothing — no .bak, no changes — and prints a message to fall back to a …

    line 775
  26. Very specific:

    Ordering: run the upgrade gate first if the file is stale.

    line 789

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Interactive Diagram

Produce a single self-contained .html file that renders a beautiful technical diagram. The user opens it by double-clicking — no install, no build, no server.

Choose the output mode first

This skill ships two templates. Pick one based on what the user wants, then copy that template and edit only its config.

ModeTemplateUse when
Interactive explorable diagramassets/skeleton.htmlThe user wants a diagram they can pan, zoom, drag, click, and explore — system/architecture, sequence, flow, database schema, or timeline. Multi-tab, dark mode, side panels, hover-chain highlight. This is the default for "draw/visualize a diagram of …".
Platform architecture poster (static)assets/platform-skeleton.htmlThe user wants a polished, slide-ready infographic of a platform/product architecture — a horizontal poster of titled tiers (bands) holding blocks on a 12-column grid, with an optional cross-cutting span and a numbered legend, in an engineering or editorial theme, exported as PNG/SVG to paste into slides/PPT. Trigger words: "platform architecture", "平台架構", "投影片 / PPT", "infographic", "capability + provider layout", "marketing-style architecture". No pan/zoom/dark-mode — it is a static poster by design.

Route by intent — and ask when it's genuinely ambiguous

Run this check first, before copying any template. Do not silently default — a wrong guess means rebuilding the whole diagram, which costs far more than one question.

  1. Platform poster when the request carries any slide / marketing / poster signal: platform architecture, 平台架構, platform diagram / 平台圖, poster, infographic, one-pager, 投影片 / 簡報 / PPT / slides / deck / pitch / board, capability columns, provider / vendor bar, "for my deck/slides", "marketing/sales diagram". The poster is static (no pan/zoom) and exports to PNG/SVG — that is its entire reason to exist.
  2. Interactive diagram when the request carries any explore / engineering signal: interactive, explore, pan / zoom / drag / clickable, click through, sequence, flow, data-flow, hover, timeline / 時間軸, roadmap / 路線圖, milestones / 里程碑, or it names concrete components/services to wire together with edges (e.g. "API, Kafka, workers, Postgres"). Precedence: an explicit slide/poster word (clause 1: poster / infographic / slides / deck / PPT / 投影片) is decisive for the poster even if "diagram" also appears; an explicit interaction/sequence word (clause 2: interactive / pan / zoom / click / explore / sequence) is decisive for the interactive mode even if "platform" also appears. Only fall through to step 3 when neither kind of explicit word is present.
  3. If neither is decisive — bare "draw our architecture", "make a diagram of X", or "platform diagram" / "平台圖" standing alone (a domain noun, no slide and no interaction word) — ask with AskUserQuestion before building:
    • header Diagram type, question "Which kind of diagram do you want?"
    • Interactive HTML — "Pan / zoom / click to explore in the browser; best for engineering docs and walking through a system."
    • Platform poster — "Static, slide-ready infographic (capability columns + provider bar), exported as PNG/SVG for a deck/PPT." Then build the chosen mode.

Why this rule exists: "platform diagram" reads as the poster to a human (it is the poster's name), but the word "diagram" alone used to fall through to the interactive default — so a poster request silently produced an explorable flowchart. Lean poster when the noun is platform / poster / infographic / slide; lean interactive when it's explore / sequence / components; otherwise ask.

The rest of this document covers the interactive mode first, then the platform poster (jump to "Platform architecture poster").

Hard constraints

  • Pure HTML + CSS + vanilla JS only. No React, Vue, Svelte, no build step, no npm, no bundler.
  • No CDN dependencies unless the user explicitly opts in. The skeleton renders everything from inline SVG and DOM events — keep it that way.
    • The one acceptable exception: if the user explicitly says "use Mermaid" or "Mermaid is fine," you may include the Mermaid CDN <script> and layer interactivity on top.
  • One file. All CSS in a single <style> tag, all JS in a single <script> tag, all markup in one HTML document.
  • Editing a pre-existing diagram file? Gate first. Before touching any diagram file you did not generate this session, run node scripts/upgrade-diagram.mjs <file> --check and upgrade if it reports stale — see Upgrading an existing diagram. Stale files predate shipped layout fixes; editing them reproduces bugs the current template already solved.
  • Never hand-roll a splice script to edit an existing file's config. A throwaway heredoc or ad hoc regex splice has repeatedly reintroduced the exact bugs scripts/patch-config.mjs exists to prevent — heredoc-escaping bugs, brittle whitespace-sensitive checks, and CRLF corruption. Use scripts/patch-config.mjs's extract/edit/apply workflow instead — see Editing an existing diagram's config.

Calibrate scope: audience, altitude, branch

Route by intent decided which template. Before you copy it, decide what the picture is allowed to show — who reads it, how far into someone else's code it goes, and, for "what I did" requests, which branch actually holds the work. Skip this and you get the July 31 Serilog session: nine turns of too-high-level → too-much-library-detail → oversimplified, because scope got picked implicitly, three times, instead of once, on purpose.

Ask when scope isn't stated

Applies to code and system diagrams — system, sequence, flow tabs that represent a codebase. It does not apply to the platform poster (audience and altitude are fixed by what a poster is for), and it does not apply to er or timeline tabs — neither has library internals to include or exclude.

  1. Scope already stated — the request names who it's for or how deep to go ("just my code, skip the library," "for the team, show how Serilog batches logs"). Treat that as answered. Don't ask.
  2. Scope not stated, and a library or framework is named or implied ("diagram how our logging works," "show the request flow through Kafka") — ask before drafting any tab.
  3. No library or framework is involved at all (your own service's internal modules, nothing external to draw a boundary around) — there is no altitude to pick. Don't ask.

When clause 2 applies, ask with AskUserQuestion before drafting any tab:

  • header Diagram scope, question "Who's this diagram for, and how deep should it go into the libraries/frameworks involved?"
  • My code only — "Libraries and frameworks appear as a single block or edge label, not expanded. Best when you're the only reader, or the audience already knows the libraries."
  • My code + library entry points — "Your code plus the specific calls/config it wires up (which sink, which provider), without expanding the library's own internals. Best for a teammate learning what you built."
  • Full system, incl. library internals — "Also diagrams how the library itself works. Best when the audience needs the library's own behavior, not just your usage of it."

If forced to pick without asking, default to My code + library entry points — it is the middle ground the Serilog session never found on its own.

Check the branch on "what I did" requests

When the request frames the subject as the user's own recent work — "what I did," "my changes," "what I built this week," "diagram my PR" — run git log --all --oneline (scoped to the relevant path if the repo is large) and git branch -a before drafting. Diagram from the branch that actually holds the work, not whatever happens to be checked out: the July 31 session built from develop while the real change lived on unmerged feat/multidb-serilog.

  • One branch plausibly holds the work — use it, and tell the user which branch you diagrammed from.
  • The checked-out branch already holds it, or several branches are equally plausible — say so in one line and proceed. This check does not get its own question; the scope question above is the only AskUserQuestion this section asks.

Not a Density substitute. This section fixes what's in scope before the first block exists. Density decides how many nodes survive once a tab is already drafted. A diagram can clear this gate and still need Density's pruning; a diagram sized correctly can still be scoped to the wrong altitude, which only this gate catches. Run this once per diagram, before you copy the skeleton; run Density per tab, mid-draft.

Starting point: the skeleton

Do not hand-write the renderer from a blank file. Start from assets/skeleton.html — a complete, self-contained baseline (theme + dark mode, toolbar, SVG defs, pan/zoom/drag, hover, side panel, SVG/PNG export) whose headline feature is a baked-in layout guard.

The skeleton is a small framework: the AI writes a three-level structure — tab → container → block — and the runtime turns it into HTML/SVG. You never hand-write elements.

Framework termWhat it isRenders as
Taba whole diagram / viewa DOM tab in the floating bar; each owns its own <svg> + layout-guard instance
Containera rectangle grouping blocks (band / lane / box)tinted band with a dashed border and a title pill
Blocka node — one rounded rect (or diamond) inside a containerfilled SVG rect, clickable, draggable

Workflow with the skeleton:

  1. Copy assets/skeleton.html to the output path.
  2. Replace only the CONFIG block (app) with the real system. Each entry in app.tabs is one independent diagram. Inside a tab, give every block a container id and define the containers array; x/y/w/h are starting hints, not final coordinates.
  3. Open it. init() builds the first tab, runs the guard automatically, and logs the audit result per tab to the console. Click ⚑ Audit overlap (⚑ 檢查重疊) to re-check the active tab at any time; if it finds overlaps it offers to auto-fix — autoFix() restores the tidy built layout (snapshotted in buildTab as tab.home) and redraws, clearing drag-induced overlaps without drifting the tuned container/title placement.

Note on chrome language. The built-in UI (toolbar, legend, side-panel headings, help modal, audit/auto-fix dialogs) ships in Traditional Chinese (zh-TW). Diagram content — tab/container labels, block labels, descriptions — is whatever you author in the app config. To switch the chrome to another language, edit the button text in the toolbar markup, TYPE_LABELS, the help modal, and the applyTheme/audit strings.

Tabs: one diagram or several

  • One system / one view → one tab. A single-tab app renders with the tab strip hidden — it looks and behaves exactly like a plain single diagram.
  • Multiple related views → multiple tabs (e.g. "System", "Sequence", "Deployment"). The tab strip appears in the floating bar automatically when app.tabs.length > 1. Each tab is built lazily on first activation and cached; each keeps its own pan/zoom, layout guard, and audit. Theme (light / dark) is shared across all tabs.

The layout guard (makeLayout(tab))

Each tab gets its own guard instance via makeLayout(tab). It checks distance / non-overlap across the four element categories a diagram is built from, and resolves collisions:

CategoryWhat it isResolver
blockblock rectangles / diamondsresolveBlocks() — BFS pin-settled push-apart within each zone, ≥67px clearance
labeltext riding on arrow (edge) linesrun() widens the gap between side-by-side connected blocks until the label pill fits on its line with line still showing either side (corridor widening); resolveLabels() then slides each label along its own curve to a spot the audit accepts
titlecontainer captionstitleBox() places each in the reserved caption strip of its own zone
containerzones grouping blockspackUnits() — zones (and free blocks) pack as rigid units from the top-left corner, never overlapping

The resolver is two-level: blocks settle inside their zone first, then whole zones — plus every free block — pack as rigid units with ≥48px zone-to-zone clearance, and the finished layout is normalized so its bounding box starts at the top-left origin (60, 60). At both levels a label-corridor pass widens the facing gap of horizontally-adjacent connected blocks until the edge's label fits on the line between them — the layout makes room for the label instead of letting it drift away from its edge.

The 67px block clearance is derived, not chosen: an edge label rides a pill LABEL_H (19px) tall, and a label only reads as belonging to a line when the line is still visible on both sides of the pill. So the gap is LABEL_H + 2×GAP.mixed + 2×LINE_STUB — pill, audit clearance, and a 16px visible stub each side. That covers vertically stacked pairs by construction; the corridor pass applies the same formula sideways, where the pill is much wider than the standing gap. Change LABEL_H / LINE_STUB in the template and the spacing follows. Containers are optional: omit the containers array (or leave a block's container unset) and those blocks pack as standalone units alongside the zones. A block naming an unknown container id logs a console.warn and is treated as free.

Two entry points (on each tab's layout):

  • layout.run() — call on the data before rendering: settles blocks inside each zone, packs zones from the top-left, then grows containers around their members. (buildTab() already does this.)
  • layout.audit() — walks every conflicting category pair and returns { ok, count, conflicts[] }, logging a console.table tagged with the tab id. Zones must not overlap: container×container overlap is a zone-overlap conflict and a block sitting on a foreign zone is a foreign-block conflict. The one sanctioned crossing is band×lane (a grid cell is the crossing region), which also exempts the crossing band's blocks and title against that lane (see POLICY.isConflict). A block inside its own container and a title captioning its own container are by design. Blocks that spill outside their declared container are reported separately as spill. The audit also samples every routed edge (curved or, on er tabs, orthogonal) against every block that isn't one of its own two endpoints, reporting a crossing as edge-through-block — this applies to every tab type, not just er.

layout.resolveLabels() runs on the DOM after render (labels need measured positions); everything else runs on the in-memory tab data. Geometry primitives (measureText, penetration, overlaps, contains) are exposed for custom layouts. Before handing over the file, confirm the console shows ✓ Layout audit [<tab id>]: no overlaps for every tab — if it logs conflicts, adjust the starting hints or container membership and reload.

The config you edit

The skeleton's <script> is already organized into commented sections — CONFIG, RENDER, INTERACTIONS, EXPORT, INIT. You touch only CONFIG; leave the rest alone unless the user asks for a genuinely new capability. Keep CONFIG self-contained and first in the script so the user can later edit just that object to change the diagram. The shape:

const app = {
  title: "Diagram",
  typeLabels: { queue: "Serilog Enricher" },  // OPTIONAL — override legend/badge
  // wording per block type; put custom legend text here (config survives
  // upgrades), never by editing the TYPE_LABELS constant in the runtime
  tabs: [
    {
      id: "system",                       // unique; used as cache key + svg/png filename
      label: "System",                    // caption shown in the tab strip
      type: "system" | "sequence" | "flow" | "er" | "timeline",

      containers: [                        // OPTIONAL — the zone rectangles
        { id, label, orient, color, title },
        // a container is a ZONE grouping the blocks of one layer / system /
        // module; zones never overlap each other (band×lane crossing excepted)
        // orient: "band" (horizontal stripe) | "lane" (vertical column) | "box" (free)
        // title.side: "above" | "left" | "top"
        // color: drives the zone's dashed border, its 10% area tint, and its
        //   title pill outline. Omit it and all three fall back to var(--edge).
        //   Pick from the ZONE palette — var(--z-blue | --z-cyan | --z-green |
        //   --z-amber | --z-red | --z-violet | --z-slate). Never a literal hex:
        //   a hex is frozen to the theme you authored it in, and the other
        //   theme moves the page out from under it. Never a --c-* type colour
        //   either: those are node strokes, near-black ink in light mode, and
        //   a zone is background chrome behind the nodes, not another node.
      ],

      blocks: [                            // the nodes
        { id, container, label, type, x, y, w, h, description, code, tech, responsibilities },
        // container = id of the zone this block belongs to; omit it (or omit
        // `containers` entirely) and the block packs as a FREE unit
        // type drives the color (client / http / worker / infra / queue / db / external)
        // code (optional) = a verbatim monospace code block shown in the side
        // panel below the description (selectable, horizontal scroll)
      ],

      edges: [
        { from, to, label, style, bendDir, fromSide, toSide },
        // style: "sync" | "async" | "dashed"
        // curve direction is auto-derived from each port's face so the
        // arrowhead always points into the target block.
        //
        // WHICH FACE an end leaves is a cone measured off the X-axis: the port
        // uses the left/right face when the other block sits within 30°
        // (PORT_H_CONE_DEG) of horizontal, and top/bottom otherwise. So a
        // block one band below but far off to the side connects top-to-bottom
        // (reads as flow), while a near-level neighbour connects side-to-side.
        //
        // face selection is ROUTING-AWARE: the cone judges geometry alone, so
        // in a dense tab it can pick an axis that then can't be routed and the
        // line ends up buried under a block. when that happens the edge is
        // retried on the perpendicular axis and whichever actually clears is
        // kept (if neither clears, the one grazing blocks least wins, ties to
        // the cone). you don't author this — it just means a correct-looking
        // cone choice never costs you a visible line.
        //
        // fromSide / toSide: "left" | "right" | "top" | "bottom" pin ONE end to
        // an exact face, for the intent geometry can't infer. Use sparingly —
        // the cone is right most of the time. A face pinned AWAY from the other
        // block forces the curve to loop around to reach it, so re-check the
        // edge afterwards. A typo warns in the console and is ignored.
        // Ignored on sequence tabs (they have their own renderer).
        //
        // whichever face an end lands on, arrival/exit ANGLE against that face
        // is corrected automatically: an end grazing its face at under 38° (it
        // would read as the arrowhead lying flat on the border instead of
        // entering it) is rotated onto the face's normal, tail length clamped
        // to a sane window. Already-steep ends are untouched. You don't author
        // this either — it's un-authored, like the retry above — and it yields
        // to obstacle clearance: a snap that would push the line into a block
        // the router had cleared is discarded and the original curve is kept.
        //
        // when no bendDir is set, the curve auto-routes around whatever sits
        // between the endpoints — it grows, flips, AND skews its bow (slides
        // the control point along the chord, so the arc can swing wide early
        // or late), taking the calmest curve that clears. obstacles are
        // tiered, because edges draw UNDER blocks:
        //   blocks       — opaque; an un-cleared block is an INVISIBLE line
        //   zones/titles — translucent chrome; crossing one is cosmetic
        // so it first looks for a curve that clears everything, and only if
        // none exists does it concede the zones to keep the line visible.
        // a zone the chord MUST cross anyway (a band/lane lying strictly
        // between the endpoints) is crossed with a calm minimal bow instead
        // of being fought.
        //
        // bendDir: 1 | -1 is a manual override for bowing parallel edges
        // apart from each other. CAUTION: it bypasses obstacle avoidance
        // entirely and is never auto-adjusted — an authored bendDir will
        // happily park a line underneath a block. Reach for it only when
        // auto-routing has visibly failed, and re-check the edge afterwards.
        // The face-snap above is the one exception: it still straightens a
        // grazing end even on a bendDir edge, because it only rotates the
        // terminal handle and never touches which side bendDir put the bow
        // on — the bow-side choice you authored is preserved exactly.
      ],

      // For type: "sequence" tabs, use `actors` + `messages` instead of
      // containers/blocks/edges:
      actors: [
        { id, label, type },               // vertical lifelines, left→right in array order
      ],
      messages: [
        { from, to, label, style, description, tech, responsibilities, activate, deactivate },
        // from === to → self-loop; from !== to → cross arrow
        // activate: true opens an activation bar on the receiver (`to`)
        // deactivate: true closes the activation bar on the sender (`from`)
        // flags are explicit (no auto-inference); re-entrant activations on the
        // same actor merge into one bar; an unclosed bar runs to the bottom box
      ],

      // For type: "er" tabs, blocks are TABLE nodes and edges are RELATIONSHIPS
      // (still declared under `blocks`/`edges` above — `containers` still works
      // as schema/module grouping — only the shape of each entry differs):
      //   blocks[].columns — [{ name, type, pk, fk, unique }], one row per
      //                      column (name + type + PK/FK/UK key marks). Omit or
      //                      leave empty for a header-only box. `x`/`y` are
      //                      still required starting hints, same as any block
      //                      — only `w`/`h` are derived from the column list.
      //   edges[] — { from, to, fromColumn, toColumn, fromCardinality,
      //               toCardinality, identifying }. `from`/`to` are still
      //               TABLE ids, exactly as on every other tab type;
      //               fromColumn/toColumn name the exact column each end
      //               anchors to (unresolvable table/column names log a
      //               console.warn and that relationship is skipped, the
      //               rest of the diagram still renders). fromCardinality/
      //               toCardinality are each one of "one" | "zero-or-one" |
      //               "one-or-many" | "zero-or-many", drawn as a Crow's Foot
      //               (IE) marker at that end. identifying: false renders the
      //               line dashed (default true = solid). from === to renders
      //               a self-referencing loop-back. Relationships route
      //               orthogonally (their own router, independent of the
      //               curved system/flow router) and skip the label-corridor
      //               pass; hovering a table highlights direct neighbours
      //               only, not the full connected chain.

      // For type: "timeline" tabs, the shape is `events` (+ optional `eras`).
      // There are no containers, no blocks and no edges — a milestone
      // chronology has no graph to route:
      events: [
        { id, date, label, note, type, side, major,
          description, code, tech, responsibilities },
        // authored in CHRONOLOGICAL order — the array IS the order on the axis
        // date  — printed inside the card in monospace. Any string; it only
        //         has to parse as a date when scale is "time".
        // label — the milestone. Wraps and grows the card, same as a block.
        // note  — OPTIONAL one short caption line under the label. The full
        //         story belongs in `description`, which the side panel shows;
        //         a note that runs past a line or two belongs there instead.
        // type  — drives the card's leading-stripe colour, from the SAME
        //         palette as every other tab (client / http / worker / infra /
        //         queue / db / external).
        // side  — OPTIONAL "above" | "below", pins the card to one side of
        //         the axis. Omit it and sides alternate. A pinned card that no
        //         longer fits its side is stacked into a further row
        //         automatically, so pinning can never cause an overlap.
        // major — OPTIONAL true. Marks a MAJOR milestone: wider leading
        //         stripe, the card's border in the type colour instead of the
        //         neutral one, a SOLID dot on the trunk among the hollow
        //         ticks, and one step up in title size.
        // NO x/y — position on the axis is DATA, not a starting hint.
      ],
      scale: "even",   // OPTIONAL. "even" (default) spaces events equally — a
                       // roadmap means order, not elapsed weeks. "time" spaces
                       // them in proportion to Date.parse(date), which is what
                       // an incident chronology needs. An unparseable date
                       // warns in the console and falls back to "even".
      wrap: undefined, // OPTIONAL. A long axis Z-WRAPS into rows, each reading
                       // left→right, joined by a return stroke down to the
                       // next. Omit it (the default) and the row count is
                       // chosen to land the picture nearest 16:9. A positive
                       // integer fixes the events per row. `false` forces one
                       // unbroken row however wide it gets.
      now: "2025-10-05", // OPTIONAL marker for the current moment. `true` uses
                       // today; a date string pins it. Drawn as a dashed rule
                       // across the row with a solid pill above it, its x
                       // interpolated between the two events it falls between
                       // (so it works on either scale). Needs every event.date
                       // parseable by Date.parse — one that isn't warns in the
                       // console and the marker is skipped.
      nowLabel: "Now", // OPTIONAL text in that pill. Defaults to "Now".
      eras: [          // OPTIONAL phase bands drawn behind the axis
        { id, label, from, to, color },
        // from/to are EVENT ids, inclusive. An era spanning a wrap point is
        // drawn once per row it touches, label and all. `color` takes a
        // --z-* zone token (never a literal hex, never a --c-* node colour —
        // same rule as a container's colour, for the same reason).
      ],

      story: [          // OPTIONAL, on any `system` / `flow` / `er` tab — a
                       // guided walkthrough. See "Walkthrough (story) steps"
                       // below for the authoring rules.
        { focus: ["node-or-edge-id", ...], say: "one-line caption" },
        // focus — block ids as authored, or an edge id written "from->to"
        //         (e.g. "gateway->authsvc"); an id that doesn't resolve is
        //         dropped with a console ✗ and that STEP is skipped — the
        //         walkthrough still boots on the remaining steps.
        // say   — ONE line, shown in the caption strip while this step is
        //         active. The mini-lesson belongs in the focused node's
        //         `description`, not here — see "Elaboration placement
        //         contract" above.
      ],
    },
    // …add more tabs for additional views; the tab strip appears automatically
  ],
};

For a single diagram, use exactly one entry in tabs — the tab strip stays hidden and the file behaves like a plain single diagram.

Walkthrough (story) steps

Any system, flow, or er tab may carry a story: an ordered set of steps that turn the diagram into a guided tour. A 導覽 button appears in the toolbar whenever the active tab's story is non-empty (hidden otherwise); stepping through it dims everything outside the current step's focus to a faint 20%, zooms/pans to just that subset, and shows the step's say caption in a strip at the bottom with a step counter and Prev/Next — ←/→ also step; the strip's close button exits the tour directly, while Esc closes only the topmost open layer (help → drawer → walkthrough) — so with the side panel open, Esc closes the panel first, not the tour. The side panel stays fully clickable throughout: clicking a focused node still opens it.

Rules for authoring one:

  • 5–9 steps. Fewer loses the plot; more is a second tab in disguise — split it instead (see Density).
  • say is one line. It is a caption, not the panel — see the Elaboration placement contract.
  • Order by TEACHING order, not data-flow order. The step that explains the mechanism best comes first, even if the request itself starts somewhere else in the picture.
  • Step 1 states why/stakes — what the reader gets from this tab and why it matters, before any mechanism.
  • One step explicitly names the likely misunderstanding — the wrong mental model a reader would otherwise bring in (e.g. "not a straight line — this edge loops back, so the cycle can repeat").
  • An unknown focus id degrades the same way an unknown container or ER relationship does: warn in the console and drop just that step, never the whole tab. Check the console after authoring a story, the same way you check it for the layout audit.
  • Adding or editing a story on an already-shipped file is a config edit like any other: go through Editing an existing diagram's config, not a hand-spliced script.

What the skeleton already gives you

The renderer, theme, and every interaction below ship in the skeleton — you don't build them. Your job is to author config so each one carries meaning (real chains to highlight, real descriptions in the panel, sensible node types for color). The behaviors, for reference:

  • Hover a node → highlights its whole connected chain (everything upstream that reaches it plus everything downstream it reaches), dimming the rest. chain() computes it, highlight() applies it. On er tabs this is direct relationship partners only, not full chain reachability — a normalised schema hovered with full-chain highlighting would light up nearly every table. On timeline tabs there is no chain at all: hover isolates the single event, and there is no drag, because an event's place on the axis is data rather than a hint.
  • Double-click → pins that chain so it persists; double-click again (or click another node) to clear. Hover still previews while pinned (state.pinned).
  • Click → a side panel slides in with the node's label, type, description, tech, and responsibilities.
  • Pan by dragging empty canvas; zoom by wheel/pinch with +, −, and Fit to screen in the toolbar.
  • Drag nodes to reposition (edges follow); nodes are clamped to the viewport.
  • Help modal documents every interaction; close via X, backdrop, or Esc.
  • Sequence step mode (type: "sequence" only) → Prev/Next reveal messages one at a time along the lifelines.
  • Long node labels preserve authored newlines, wrap automatically, and grow their block before layout runs.
  • Connection-line and edge-label color pickers provide presentation-safe contrast; reset restores the current theme defaults.
  • Dark mode, Download SVG, and Download PNG all live in the toolbar. PNG supports original 2×, fixed 16:9 (1920×1080), and fixed 4:3 (1600×1200) output without stretching. Exports inline computed SVG styles and include an opaque theme background. The legend travels with the picture: both exports redraw it as real SVG and grow the crop's short axis to hold it — a horizontal caption strip under a landscape diagram, an upright panel beside a portrait one — so a file never arrives with its colours unexplained. Growing the long axis instead would drive the frame further from any slide aspect, and the PNG presets would then shrink the whole picture to fit. The legend never renders larger than it does on screen; it shrinks (to a floor of 0.65×) only when it would otherwise take more than 18% of the axis it grows along.

The diagram renders as inline SVG (interactive, scalable, exportable — not Canvas), with rounded nodes, soft shadows, curved/orthogonal edge routing, a per-type color legend, and a system font stack. The aesthetic target is clean and modern — think Linear, Vercel, or Stripe docs.

What you author, and why it matters

The skeleton handles geometry; you handle meaning. Two things are yours to get right:

  • Container membership and starting hints. A container is a zone — an area grouping the blocks of one layer / system / module — and it is optional: blocks without a container (or a tab with no containers at all) pack as free units alongside the zones. x/y/w/h are hints, not final coordinates — layout.run() settles blocks inside their zone, packs zones from the top-left corner so nothing overlaps, and grows containers around their members. Group blocks the way the system actually decomposes (by tier, lane, or stage) so the auto-layout has a sensible starting shape to refine.
  • Node type and content. type drives color, so pick the one that reflects each node's role (client / http / worker / infra / queue / db / external). Fill description, tech, and responsibilities — an empty panel is a dead click.

Pick the diagram type from what's being described: system for services and data stores, sequence for time-ordered messages between actors, flow for decision/process flows, er for a database schema / entity-relationship diagram (tables with columns and Crow's Foot relationships), timeline for a dated chronology — roadmap, release history, project schedule, incident timeline. If the request is ambiguous — missing components, unclear flow direction, unclear sync vs async — ask before inventing components.

For er tabs, notation is Crow's Foot (IE) — the de facto standard. Data is hand-authored only (no DBML/SQL/Mermaid importer, no liz-whiteboard integration): write the columns and relationship fields directly from the user's description, the same way you'd author any other tab. Express a many-to-many relationship as a junction table plus two one-to-many relationships, never a single M:N edge. A composite foreign key is one relationship per column pair (no bracketed-group notation). bendDir doesn't apply to ER edges — the orthogonal router ignores it. fromSide/toSide apply only partially: on an end that names a column the row is a horizontal band, so only "left"/"right" can be honoured ("top"/"bottom" is contradictory and ignored); an end with no column behaves like any other tab. So for ER, re-check the rendered diagram after authoring and rearrange tables (drag, or edit starting x/y) if a relationship routes awkwardly.

For timeline tabs, author events in chronological order and let the renderer do the rest — there is no layout guard on a timeline and nothing to drag, because the position along the axis is the data. Three calls are yours:

  • scale. Default "even" unless the gaps themselves carry meaning. A roadmap or release history is "even": nobody reads a roadmap to learn that Q3 was quiet. An incident or outage chronology is "time": there, "and then nothing happened for six hours" is the finding.
  • note vs description. The note is one line, and it is what a reader gets from the exported PNG, where nothing is clickable. Put the single fact that makes the milestone matter there; put the rest in description.
  • eras. Group the milestones into phases when the project actually had phases. Skip them when it didn't — an era per milestone is noise, and the bands then say nothing the dots don't already say.
  • major. Reserve it for the two or three dates that decide whether the thing succeeded — a launch, a cutover, an audit that had to pass. Emphasis is a ratio, not a property: mark half the milestones and you have marked none of them. If the user's list has no obvious peaks, leave it off rather than picking some.
  • now. Add it whenever the diagram is about work still in flight — it turns a list of dates into a position, which is the first thing anyone asks of a roadmap. Prefer true for a living document that gets reopened, and pin an explicit date only when the picture is a record of a specific moment (a status report, a retro, a slide dated in its own footer).

Leave wrap alone unless you have a reason: the automatic row count aims the finished picture at 16:9, which is what makes it paste into a slide. Set an integer only to force a break at a meaningful boundary (a year, a phase); set false only when the user explicitly wants one unbroken strip.

For sequence tabs, mark activate/deactivate on messages where an actor is doing active work — the renderer draws a UML-style activation bar on that actor's lifeline spanning the marked rows.

Elaboration placement contract

A diagram has several places to put an explanation — a node's label, an edge's label, the side panel, a tab's own framing, a walkthrough caption. Each place earns a different KIND of content. Putting the wrong kind in the wrong place is why a diagram either says nothing, or says too much in the wrong spot.

SurfaceCarriesNever
Node face (the label)A noun phrase plus one concrete real-repo example, in ≤1 line — e.g. Gateway (kong), not Handles auth for every upstream request.A sentence. If you are explaining inside a label, the structure — not the label — is failing to show the mechanism; fix the diagram, don't pad the noun.
Edge labelThe verb, or the data that moves — prompt, context, feedback.A justification for why the edge exists; that belongs in the block's description.
Tab intro (the tab's own framing, e.g. a leading block's description or a story's step 1 — see Walkthrough (story) steps)Why this view matters and what's at stake, plus the misreading a reader is likely to bring to it — stated before any mechanism.A walkthrough of the mechanism itself; that is what the rest of the diagram is for.
Detail drawer (the side panel)A mini-lesson: what the node is, why it exists, and a real code excerpt (code field) when one clarifies it.A restatement of the label.

One question per tab. A tab answers ONE question a reader would ask — "how does a request reach the database?", "what happens when a tool call fails?". A new concern is a new tab, not a new zone bolted onto an existing one. Two tabs that turn out to answer the same question are a sign to merge them, not a reason to keep both for coverage.

Accepted changes land in the HTML, never chat-only. If a reviewer approves wording, or a re-placement, in conversation, write it into the config before calling the work done — a description that exists only in the chat transcript does not ship.

Density: subtract, then zone, then split

The layout guard proves that nothing overlaps. It cannot prove that the diagram reads. A forty-block tab whose blocks were all pushed apart passes the audit and is still unusable — the guard resolved the collision by growing the canvas, which is the wrong answer for a reader. Density is yours to manage before the guard ever runs. Work in this order.

1. Subtract first

The highest-quality edit is usually a deletion. Before you write the config, cut:

  • Blocks that always travel together. If two blocks are never mentioned apart and nothing routes between them separately, they are one block. A gateway, its sidecar, and its config loader are "the gateway".
  • Edges the layout already implies. If a block sits inside a zone whose caption already states the relationship, the line adds ink and no meaning.
  • Decorative blocks. A block with no description worth reading, nothing in responsibilities, and no edge that changes how the diagram is understood is scenery.

The test: would the reader learn more from this tab than from a well-written paragraph? If not, don't draw it. Never invent a component to fill a gap in the layout, and keep the user's own vocabulary for proper nouns.

2. Zone when dense

container is optional in the schema and not optional past about a dozen blocks in a tab. Blocks without a container pack as free units, so a dense tab of free blocks reads as one undifferentiated field — the eye has nowhere to start. Above roughly twelve blocks in a tab, or when more than six blocks carry no container, group them the way the system actually decomposes (tier, lane, or stage).

Zoning is not a consolation prize for a big diagram. It is what makes a big diagram readable, so reach for it before you cut further and well before you split.

3. Offer the node-necessity review

Those numbers are approximate on purpose — they gate a question, not a rejection. An eighteen-table ER schema is legitimately eighteen tables; a fourteen-message protocol is the protocol. Counting cannot separate a bloated diagram from a faithful one, and deciding that two blocks always travel together is a judgement about meaning, not quantity.

So when a drafted tab is past the threshold, ask before writing the file, with AskUserQuestion:

  • header Node review, question "This tab has N blocks. Run a necessity review before I write it?"
  • Run the review — "A subagent reads the drafted config and proposes which blocks to merge, drop, or move to a second tab. You approve before anything changes."
  • Write it as drafted — "Skip the review and ship the tab as authored."

If the user accepts, spawn one subagent, hand it the drafted config object, and have it walk this ladder top-down — stopping as soon as the tab reads:

  1. Decorative blocks — no description, no responsibilities, no edge that changes meaning.
  2. Duplicates — blocks that always travel together, merged and renamed.
  3. Leaf clusters — three or more siblings hanging off one parent that differ only by index (worker-1…n becomes one "workers ×N" block).
  4. Degree-1 sinks — one inbound edge, nothing downstream, and a story already told by its caller's description.
  5. Cross-cutting infra — logging, metrics, secrets. Real, but present on every path, so they belong in panel text or their own zone rather than as edges to everything.
  6. Split — whatever still stands goes to a second tab (see below).

Never cut ad hoc; the order is the point. Two rules bind that subagent:

  • It proposes, and never edits the config. You apply what the user approves.
  • It returns a fidelity ledger — every block it recommends removing, named, with its reason. Report that ledger to the user verbatim. The reader of a diagram cannot see what is missing from it; the person who asked for it needs to know.

4. Split last

Split into a second tab only once zoning has stopped helping — when the zones themselves have become too many to scan, or when one tab is answering two different questions. Splitting beats shrinking: never solve density by shipping smaller blocks or shorter labels, because a tab that only fits after the type shrinks is a tab whose detail level was wrong.

Tabs are cheap and the reader pays nothing for them. A single-tab app renders with the tab strip hidden, so the strip appears exactly when a second view earns its place. The usual split is an overview tab (the systems and the flow between them) plus one detail tab per subsystem that needs it — each tab keeps its own layout guard and audit.

Before you hand it over

The layout guard does the overlap work; your job is to confirm it succeeded and that the content reads well. Open the file and check:

  • Density holds. No tab is a field of ungrouped blocks — past about a dozen blocks they sit in containers — and if you offered the node-necessity review, the approved cuts are applied and its fidelity ledger was reported to the user.
  • The console shows ✓ Layout audit [<tab id>]: no overlaps for every tab. If a tab logs conflicts, adjust that block's container membership or starting hints and reload — don't ship overlaps.
  • The console's ◆ interactive-diagram vX.Y.Z line matches the current template version. If it's older, the file is stale — run node scripts/upgrade-diagram.mjs <file> before handing over (see Upgrading an existing diagram).
  • Edited an existing file's config rather than writing a fresh one? Confirm it went through patch-config.mjs --extract/--apply, not a hand-spliced script, and that <file>.bak exists from the apply step.
  • Every node's panel has real content, the chain highlight follows the true flow, and the legend matches the node types you used.
  • Long labels wrap without clipping, and the layout audit still passes after blocks grow.
  • Dark mode, Fit to screen, custom line/label colors, and both exports work.
  • Nothing hides under the chrome. On load, and after ⊡ Fit to screen, no block or edge sits beneath the floating toolbar or the legend. fit() measures both and frames into what they leave, but the audit only proves that blocks don't overlap each other — it never looks at the viewport, so a tab can log no overlaps while its top row is invisible. Check with your eyes.
  • Verify PNG in original, 16:9, and 4:3 modes; connectors, labels, lifelines, the legend panel, and the theme background must remain visible in the downloaded files.
  • The exported legend reads the same model as the on-screen one, so it can never drift — but check that its text is legible at export scale and that the reserved band sits clear of the diagram.
  • For er tabs specifically: relationship lines terminate at the correct column rows (not block edges), both cardinality markers render at each end, non-identifying relationships are visibly dashed, and a self-referencing relationship loops back without crossing its own table.
  • For timeline tabs specifically: the console logs no audit line for them, and that is correct — they run no layout guard, so ⚑ reports "no overlaps" without having checked anything. Judge them with your eyes instead: every card clear of its neighbours, the dates reading in order along each row, and, where the axis Z-wrapped, the return stroke landing in the gap between rows rather than through a card. Watch the console for [timeline] warnings — an unparseable date under scale: "time", an out-of-order event, an era naming an event that doesn't exist, or a now marker that can't be placed all warn there and then degrade quietly.

If the audit can't clear a stubborn tab on its own, the ⚑ Audit overlap → auto-fix button restores the tidy built layout; reach for it before hand-tuning coordinates.


Platform architecture poster (static)

A second template for a static, slide-ready architecture poster — the kind you paste into a deck. It is not interactive: no pan/zoom/dark-mode/panels. The headline feature is a tier-and-block layout model — bands (tiers) of blocks on a 12-column grid, rendered as titled zones — with a dual engineering / editorial theme, exporting crisply to PNG (for PPT) and SVG on a white background with white padding.

Starting point

Copy assets/platform-skeleton.html to the output path and edit only the config object at the top of the script. Worked examples live at examples/platform-architecture.html, examples/ai-agent-platform-poster.html, and examples/pulsepay-platform-poster.html.

The poster is composable: config.bands is an ordered list of tiers, each a zone (titled region) holding blocks[] placed on a 12-column grid via {col, span}. Each band renders top-to-bottom and joins to the next by a connector. config.spans[] draw a cross-cutting region (e.g. security/observability) beside a range of bands. config.legend[] adds a numbered caption list. To change the picture, add / remove / reorder bands or blocks or edit their data — the layout restacks and the artwork height (and viewBox) recompute automatically. The chrome (export buttons, ⚑ 檢查溢出/重疊 audit button, hint) ships in zh-TW; all diagram text lives in config, so it is fully localizable.

Config shape

const config = {
  meta: {
    title, sub, theme: "engineering", // | "editorial"
    width: 1440, pad: 32, margin: 44,
    cols: 12, gutter: 16, bandGap: 22,
    zonePad: 20, capStrip: 38,        // vertical density levers
    fileName: "platform-architecture",
  },
  bands: [ /* ordered tiers; each has an `id`, `kicker`, `label`, `zone`, `blocks[]`, and optionally `connect` */ ],
  spans: [ /* cross-cutting regions across a band range */ ],
  legend: [ /* numbered caption list */ ],
};

connect on a band selects the connector drawn in the gap below it (unchanged meaning from a plain stacked layout). zone is one of the --z-* tokens shared with the interactive skeleton: blue · cyan · green · amber · red · violet · slate.

Band, block, span and legend shapes

Band fieldMeaning
idreferenced by spans[].from/to and legend[].ref
kickershort tier number/tag, e.g. "01"
labelthe tier name, shown in the zone's title pill
zonea --z-* token key — the zone's tint
emphasistrue renders a stronger treatment for the subject tier
flow"numbered" draws a numbered badge on each card
connectconnector drawn below this band (see Connector types)
strip{ title, sub } — renders a dark full-width strip instead of blocks[] (e.g. an API gateway)
blocks[{ type, col, span, … }] — see Block types below
type (block)RendersKey fields
carda titled content cardic, a (accent index), title, en (kicker tag), lines:[…], paren, tags:[…]
chipa small logo + name tilename, logo, g (brand colour) — or more:true for a dashed "+ …" placeholder
nodea small circular hub badgelabel (string or [line1, line2]), a
spans[] fieldMeaning
col, spangrid placement, same convention as a block
from, toband ids — the vertical range this span covers
zonea --z-* token key
title, en, linescontent, same visual treatment as a card

A span reserves its columns for its whole band range: every band container in that range stops short of the span's leftmost column, so the span reads as a region beside them, never a box drawn on top of them.

legend[] entries are { n, ref, text } — ref (e.g. "flow.0", matching a band.id + block index) is authoring metadata only; the marker actually shown is the numbered badge already on that block (flow: "numbered"), reused so a legend entry and its card ordinal always look the same.

Connector types (connect)

valuedraws
careta vertical line ending in a downward chevron at the gap's midpoint
arrows-downshort down-arrow stubs under each block (band → next)
dashed-convergedashed curves converging from across the band's bottom to the next band's top anchor
dashed-linea straight dashed line between the two anchors
none (default)gap only

Icons

Icons are a small inline Lucide (MIT) set in the LUCIDE map (upload, scan, edit, check, report, plug, flow, agent, db, shield, sparkles). To add one, paste its 24×24 inner markup as a new LUCIDE entry and reference it by key. An unknown icon name logs a console.warn and renders nothing.

Theming

config.meta.theme is "engineering" (resolved Carbon White tokens, Carbon's 14-colour categorical accent ramp, monospace kickers) or "editorial" (measured Stripe-style tokens, a single accent colour by design, humanist type). Every renderer reads the resolved token set T — no renderer contains a literal colour; the only colour sources are THEMES (per-theme tokens), ZONES (theme-independent zone tints), LUCIDE (icon paths, not colour), and a block's own authored brand mark (e.g. a chip's g field). Switching theme re-skins the whole poster with no data changes.

Overflow & overlap audit

The poster has a layout audit — the static-poster analogue of the interactive skeleton's layout guard. Because bands stack deterministically top-to-bottom and blocks sit on a fixed grid, the failure modes aren't free-form collisions but: (1) text wider than the box it sits in (a card title or body line, a chip name, a zone title pill, a legend caption) and (2) sibling boxes packed so tight they overlap (too many chips, a block spilling past its own band's — possibly span-narrowed — container). The audit detects both:

  • Each renderer records what it draws into a per-build collector (ctx.audit): audit.box(id, group, …) registers a rectangle for sibling-overlap checks; fitText(audit, …) registers that a string must stay inside an owner box (horizontal containment, measured with a canvas measureText against the real font). Zone container rects, title pills, span regions, and legend rows all call audit.box(...); every pill label, band label, and legend caption calls fitText(...).
  • Measurement is tracking-aware: mwls(s, size, weight, mono, ls) adds ls * s.length to the raw canvas measurement, because canvas measureText() knows nothing about SVG letter-spacing — browsers apply tracking after every glyph including the trailing one. Every fitText() call and every layout calculation that sizes a box around tracked text (e.g. a zone's title pill) goes through mwls(), never the raw canvas measurement alone.
  • evalAudit() walks the records and returns { ok, count, conflicts[] }. It runs automatically on load — logging ✓ 版面檢查:無溢出或重疊 or a console.table of conflicts, and updating the toolbar hint with a pass/fail badge.
  • ⚑ 檢查溢出/重疊 re-runs it on demand. If the layout is clean it just confirms so; if it finds conflicts it paints red dashed overlays on each offender (red = text overflow, magenta = box overlap) and offers to auto-fix in the same dialog (OK = auto-fix, Cancel = keep the overlays). Click the button again to clear the overlays. Overlays are never baked into a PNG/SVG export.
  • Auto-fix (offered from that ⚑ dialog — no separate button) re-runs build() + evalAudit() in a loop against three safe levers, in order, until the audit passes (or caps are hit): (1) grow config.meta.width (overlaps and width-derived overflow ease as the artwork widens, no font-size change); then (2) shrink config.meta.zonePad / config.meta.capStrip (each zone's own padding and caption-strip height) to ease vertical squeeze once width is capped; then (3) lower a global text SCALE (consulted by both tx() and the measurement functions, so the audit always matches what is drawn) to shrink every glyph uniformly and clear overflow on fixed-size owners. It mutates config.meta + SCALE and re-renders, so PNG/SVG exports pick up the fixed layout. If a single token is simply longer than its box, auto-fix reports what it could not resolve and leaves the red overlay — shorten that one string by hand.

Before handing over, confirm the console shows ✓ 版面檢查:無溢出或重疊. If it reports conflicts, run ⚑ 檢查溢出/重疊 and accept the auto-fix offer (or shorten the offending text / reduce item counts and reload) — don't ship overflow. To audit a new element, call fitText(...) (text) or ctx.audit.box(...) (rectangle) from your renderer; everything else is automatic. The inline en kicker tag on a card is intentionally not audited (its x is a rough heuristic offset from the title, so bounding it would be noisy).

Extending the framework

  • New block type: add a renderer to the BLOCKS registry keyed by the type name. It receives (block, ctx) — ctx = { x, y, w, h, band, idx, accent, zone, T, audit } (the resolved cell geometry, the owning band, the resolved theme tokens, and the layout-audit collector) — and returns an SVG string. x/y/w/h are already resolved from {col, span}; a block renderer never computes its own column maths. Register any text/boxes you draw with audit so the overflow/overlap check covers them, and read colour only from T / ZONES / the block's own authored fields (D4 — no literal colour).
  • New connector: add a function to the CONNECTORS registry keyed by name; it receives (fromPorts, toPort, cctx, T) (cctx = { x, w, cx, fromY }) and returns an SVG string.
  • Reuse the helper vocabulary: icon(), tx(), mw(), mwls(), zoneChrome() (the band/span zone chrome, copied by value from the interactive skeleton's renderContainer()), and the THEMES / ZONES token objects.

Poster quality checklist

Before handing over, open the file in a browser and confirm:

  • Layout audit passes — the console shows ✓ 版面檢查:無溢出或重疊 and the toolbar hint reads ✓ 版面 OK. If it reports conflicts, click ⚑ 檢查溢出/重疊 and accept the auto-fix offer to auto-resolve them (or shorten the text / reduce item counts and reload). See Overflow & overlap audit.
  • Version line is current — the console's ◆ interactive-diagram vX.Y.Z matches the current template version; if older, run node scripts/upgrade-diagram.mjs <file> first.
  • Edited an existing file's config rather than writing a fresh one? Confirm it went through patch-config.mjs --extract/--apply, not a hand-spliced script, and that <file>.bak exists from the apply step.
  • Text fits its box — card titles/body lines, chip names, zone title pills, and legend captions don't spill outside their rectangles (the audit catches these).
  • Bands and spans don't overlap — a span's reserved columns are never covered by a sibling band container, and connectors land on the right anchors.
  • Both themes render clean — reload with config.meta.theme set to each of "engineering" and "editorial" and re-run the audit.
  • White export padding present — the artwork isn't flush to the edge.
  • Export works — ↓ PNG(貼到 PPT) downloads a 2× PNG and ↓ SVG downloads valid SVG with no unresolved var(--…).

Maintaining the shipped examples

Every file under examples/ is a self-contained snapshot that embeds the template runtime it was generated from — editing a skeleton does not update them. After changing assets/skeleton.html or assets/platform-skeleton.html, run node scripts/regen-examples.mjs to splice each shipped file's authored content (its config object + <title>) into the current template, then open every regenerated file and confirm its layout audit passes before shipping.

Both templates carry a <meta name="generator" content="interactive-diagram vX.Y.Z" /> stamp in their <head> (right after the viewport meta). Bump this version string only in the template(s) you actually changed on every feature ship, alongside the commit-message version — scripts/upgrade- diagram.mjs detects staleness per template independently, so bumping an untouched template falsely marks its shipped files stale. regen- examples.mjs propagates the version to every shipped file for free since the splice re-hosts the whole template head.

Upgrading an existing diagram

This is a mandatory gate, not an on-request service. Before editing ANY pre-existing diagram file — whether the user asked for an upgrade or just an edit — run node scripts/upgrade-diagram.mjs <file> --check. If it reports a stale version, upgrade before making the requested change. History shows why: every dateable toolbar-overlap collision came from files generated before the v1.19.2 chrome-inset fix and edited for weeks without ever being upgraded — the template fix existed the whole time. Never hand-splice the runtime; run the script:

node scripts/upgrade-diagram.mjs <file> [--check] [--force]
  • Run with --check first: it prints fileVer -> targetVer and makes no changes. Staleness is also visible without the script — both templates print their generator stamp (◆ interactive-diagram vX.Y.Z) in the boot console next to the layout-audit line, so the console check you already perform shows the file's version.
  • A bare run re-hosts the file's config object and <title> onto the current matching template (auto-detected: const app = { -> the interactive skeleton, const config = { -> the platform poster) and overwrites the file in place. If the file is already current, it reports "up to date" and changes nothing unless you pass --force.
  • Report the printed vOLD -> vNEW to the user and tell them to reopen the file and confirm the layout audit passes.
  • Config-only contract: only the config object and <title> are the user's authored content — everything else is the template. Any manual edits made outside the config block are replaced by the current runtime on upgrade. This is expected, not a bug; the original is preserved at <file>.bak before the overwrite, in case those edits need recovering.
  • If the script aborts (ambiguous or missing config marker), it writes nothing — no .bak, no changes — and prints a message to fall back to a manual splice instead.

Editing an existing diagram's config

Never edit an existing diagram file's config with a heredoc or a hand-rolled splice script. Field sessions that tried it hit the same three bugs repeatedly: heredoc-escaping mistakes corrupting the config, a whitespace- sensitive check breaking after a harmless reformat, and a raw string splice silently corrupting CRLF line endings. scripts/patch-config.mjs exists to make all three impossible by construction — use it for any config change to a file you did not just generate this session.

Ordering: run the upgrade gate first if the file is stale. patch-config.mjs assumes the file is already on the current template; it only touches the config object, never the runtime.

node scripts/patch-config.mjs <file> --extract <config.js>
# edit <config.js> with a normal file-edit tool
node scripts/patch-config.mjs <file> --apply <config.js>
  • --extract <out> locates the file's config object (auto-detected: const app = { for the interactive skeleton, const config = { for the platform poster) and writes it, LF-normalized, to <out> — a plain standalone .js file you edit like any other source file. The diagram file itself is never touched by --extract.
  • Edit <out> with your normal file-edit tool — add, remove, or change whatever config fields the request calls for. It's just JavaScript object- literal source; comments are fine.
  • --apply <in> re-locates the file's config span fresh (never reusing an offset from --extract), checks that <in> still declares the same top-level const the file expects (a structural check, so reformatting or reindenting <in> never trips it), syntax-checks <in> as JavaScript, and — only if both checks pass — splices it back over the located span. It re-hosts the edit onto the target file's own dominant line ending (\r\n or \n), so a CRLF file stays CRLF and an LF file stays LF; everything outside the config span is untouched byte-for-byte.
  • On success, the pre-apply file is backed up to <file>.bak before the atomic write — same safety pattern as upgrade-diagram.mjs. On any failure — ambiguous/missing marker, a syntax error in <in>, or a mismatched top-level variable — the command aborts, prints the failure, and writes nothing: no change to <file>, no .bak.
  • Reopen the file and confirm the layout audit passes after every apply, the same as after an upgrade.

Deliverable

One .html file. Open it. It works. The user can edit the config object at the top and reload to change the entire picture — app for the interactive skeleton, config.bands for the platform poster.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.