Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ jobs:
- run: npm run validate
- run: npm run export:images
- run: npm run export:pptx
- run: npm run export:pdf
- run: npm run export:svg
30 changes: 23 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ tooltip on the "At a glance" panel's Statefulness row. Every distinct
`statefulness` value used in `PATTERNS` must have an entry here —
`scripts/validate-patterns.js` enforces this.

The tooltip text is also exposed to assistive tech: the Statefulness value
has `aria-describedby` pointing at a visually-hidden (`.sr-only`) span
holding the same string, since the `data-tip`/`::after` CSS tooltip content
isn't reliably read by screen readers on its own.

### Deep dive button

Each expanded card has a "Deep dive" button (`copyDeepDive` /
Expand Down Expand Up @@ -141,11 +146,20 @@ Color each slide accent from the token system.
Output: `exports/ai-agent-patterns.pptx`

### 4. PDF export
Use Playwright to print `index.html` to PDF.
Script target: `export-pdf.js`. Opens `index.html`, switches to `print`
media emulation (a `@media print` block in `index.html` hides the toolbar
and keeps cards from splitting across page breaks), and prints to A4 via
Playwright's `page.pdf()`.
Output: `exports/ai-agent-patterns.pdf`

### 5. SVG per pattern card
Extract each card as a standalone SVG for embedding in docs/slides.
Script target: `export-svg.js`. Expands each card in turn and pulls its
`svg.diagram` flow diagram (the same SVG shown on the page), then bakes
every element's resolved `fill`/`stroke`/`font-*`/`filter` etc. into
inline `style` attributes via `getComputedStyle` and inlines the shared
`#arrow` marker and `#nodeShadow` filter `<defs>` — so each file has zero
dependency on `index.html`'s stylesheet or CSS custom properties and
renders correctly opened on its own or pasted into docs/slides.
Output: `exports/svg/pattern-{slug}.svg`

---
Expand All @@ -171,7 +185,8 @@ ai-agent-patterns/
│ ├── validate-patterns.js
│ ├── export-images.js
│ ├── export-pptx.js
│ └── export-pdf.js
│ ├── export-pdf.js
│ └── export-svg.js
└── .github/
└── workflows/
├── deploy.yml ← GitHub Pages deploy action
Expand Down Expand Up @@ -225,10 +240,11 @@ the real `PATTERNS` data and renderer rather than a separate copy:
no duplicate names, and that every distinct `statefulness` value has
a matching entry in `STATEFULNESS_INFO` so the tooltip never renders
empty).
2. `npm run export:images` / `npm run export:pptx` — runs the real export
scripts end-to-end as a smoke test. A thrown error here means the
renderer or PATTERNS data broke something the schema check can't see
(e.g. a layout exception), not just a malformed field.
2. `npm run export:images` / `npm run export:pptx` / `npm run export:pdf` /
`npm run export:svg` — runs all four real export scripts end-to-end as
a smoke test. A thrown error here means the renderer or PATTERNS data
broke something the schema check can't see (e.g. a layout exception),
not just a malformed field.

This does not catch visual regressions (e.g. a node clipped at the edge
of its SVG `viewBox`) — only crashes and schema violations. Visual
Expand Down
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ explanation). The "Deep dive" button copies a ready-to-paste prompt
following up in a Claude conversation.

The same `index.html` is the source of truth for every other export format
in this repo (LinkedIn images, PPTX slides, PDF) — all of them render
directly from the `PATTERNS` array defined in that file.
in this repo (LinkedIn images, PPTX slides, PDF, per-pattern SVGs) — all of
them render directly from the `PATTERNS` array defined in that file.

![Site preview](docs/preview.png)
<!-- Static snapshot for GitHub's README renderer. Regenerate with
Expand Down Expand Up @@ -68,11 +68,13 @@ npx playwright install chromium
npm run validate # checks PATTERNS against the schema in CLAUDE.md
npm run export:images # exports/linkedin-{all,single,multi}.png + exports/card-{slug}.png
npm run export:pptx # exports/ai-agent-patterns.pptx
npm run export:pdf # exports/ai-agent-patterns.pdf
npm run export:svg # exports/svg/pattern-{slug}.svg — standalone, no external CSS
```

`npm run validate` and both export scripts also run on every pull request
via `.github/workflows/ci.yml`, so a malformed pattern or a renderer crash
is caught before merge.
`npm run validate` and all four export scripts also run on every pull
request via `.github/workflows/ci.yml`, so a malformed pattern or a
renderer crash is caught before merge.

## Project structure

Expand All @@ -88,11 +90,15 @@ ai-agent-patterns/
│ ├── linkedin-single.png
│ ├── linkedin-multi.png
│ ├── card-*.png
│ └── ai-agent-patterns.pptx
│ ├── ai-agent-patterns.pptx
│ ├── ai-agent-patterns.pdf
│ └── svg/pattern-*.svg
├── scripts/
│ ├── validate-patterns.js
│ ├── export-images.js
│ └── export-pptx.js
│ ├── export-pptx.js
│ ├── export-pdf.js
│ └── export-svg.js
└── .github/workflows/
├── deploy.yml ← GitHub Pages deploy action
└── ci.yml ← PR validation (schema + export smoke test)
Expand Down
19 changes: 17 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,14 @@
border-bottom: 1px dotted var(--text-tertiary);
position: relative;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.meta-val[data-tip]:hover::after,
.meta-val[data-tip]:focus::after {
content: attr(data-tip);
Expand Down Expand Up @@ -428,6 +436,13 @@
.compact .card-sub { display: none; }
.compact .flow-row { display: none; }
.compact .card-name { font-size: 12px; }

/* ── Print (used by scripts/export-pdf.js) ── */
@media print {
.toolbar { display: none; }
.card { break-inside: avoid; }
.section-row { break-after: avoid; }
}
</style>
</head>
<body>
Expand Down Expand Up @@ -503,7 +518,7 @@ <h1 class="header-title">AI agent <span>design patterns</span></h1>
<!-- Footer -->
<footer class="site-footer">
<span>AI Agent Design Patterns · Interactive Reference</span>
<span>Click any card to expand · Built with Claude</span>
<span>Click any card to expand</span>
</footer>

</main>
Expand Down Expand Up @@ -1014,7 +1029,7 @@ <h1 class="header-title">AI agent <span>design patterns</span></h1>
<div class="meta-row"><span class="meta-key">Complexity</span><span class="meta-val">${p.complexity}</span></div>
<div class="meta-row"><span class="meta-key">Agent count</span><span class="meta-val">${p.agents}</span></div>
<div class="meta-row"><span class="meta-key">Loop type</span><span class="meta-val">${p.loops}</span></div>
<div class="meta-row"><span class="meta-key">Statefulness</span><span class="meta-val" data-tip="${STATEFULNESS_INFO[p.statefulness] || ''}" tabindex="0">${p.statefulness}</span></div>
<div class="meta-row"><span class="meta-key">Statefulness</span><span class="meta-val" data-tip="${STATEFULNESS_INFO[p.statefulness] || ''}" aria-describedby="statefulness-tip-${p.origIdx}" tabindex="0">${p.statefulness}</span><span id="statefulness-tip-${p.origIdx}" class="sr-only">${STATEFULNESS_INFO[p.statefulness] || ''}</span></div>
</div>
</div>`;

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"scripts": {
"validate": "node scripts/validate-patterns.js",
"export:images": "node scripts/export-images.js",
"export:pptx": "node scripts/export-pptx.js"
"export:pptx": "node scripts/export-pptx.js",
"export:pdf": "node scripts/export-pdf.js",
"export:svg": "node scripts/export-svg.js"
},
"devDependencies": {
"playwright": "^1.47.0",
Expand Down
31 changes: 31 additions & 0 deletions scripts/export-pdf.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const path = require('path');
const fs = require('fs');
const { chromium } = require('playwright');

const ROOT = path.resolve(__dirname, '..');
const INDEX_HTML = path.join(ROOT, 'index.html');
const OUTPUT_FILE = path.join(ROOT, 'exports', 'ai-agent-patterns.pdf');

async function main() {
fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true });

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1130, height: 900 } });
await page.goto(`file://${INDEX_HTML}`);
await page.emulateMedia({ media: 'print' });

await page.pdf({
path: OUTPUT_FILE,
format: 'A4',
printBackground: true,
margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' },
});
console.log(`Saved ${OUTPUT_FILE}`);

await browser.close();
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
99 changes: 99 additions & 0 deletions scripts/export-svg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
const path = require('path');
const fs = require('fs');
const { chromium } = require('playwright');

const ROOT = path.resolve(__dirname, '..');
const INDEX_HTML = path.join(ROOT, 'index.html');
const OUTPUT_DIR = path.join(ROOT, 'exports', 'svg');

// CSS properties that style the flow diagram nodes/edges/labels (see the
// .node-*, .edge*, .node-label, .edge-label, .group-box rules in index.html).
// Baked into inline `style` attributes so each exported SVG is standalone —
// no external stylesheet or CSS custom properties required to render it.
const BAKED_PROPS = [
'fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap',
'filter', 'font-family', 'font-size', 'font-weight', 'letter-spacing',
'text-anchor', 'dominant-baseline',
];

function slugify(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}

async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`file://${INDEX_HTML}`);

const names = await page.evaluate(() => PATTERNS.map((p) => p.name));

for (let i = 0; i < names.length; i++) {
await page.evaluate((idx) => toggleCard(idx), i);
await page.waitForTimeout(150);

const markup = await page.evaluate((bakedProps) => {
const svg = document.querySelector('.card.expanded .diagram-wrap svg.diagram');
if (!svg) return null;

const bakeOne = (el) => {
const cs = getComputedStyle(el);
const decls = bakedProps
.map((prop) => {
let v = cs.getPropertyValue(prop);
if (!v) return '';
if (prop === 'filter' && v.includes('#nodeShadow')) v = 'url(#nodeShadow)';
return `${prop}:${v}`;
})
.filter(Boolean)
.join(';');
if (decls) el.setAttribute('style', decls);
if (el.classList.contains('edge-label')) {
el.textContent = el.textContent.toUpperCase();
}
};

// Resolve the shared marker/filter defs (var(--border-mid) etc. only
// resolve while still attached to the live document) before cloning.
const sprite = document.querySelector('body > svg[aria-hidden="true"]');
const markerPath = sprite.querySelector('marker#arrow path');
const filterEl = sprite.querySelector('filter#nodeShadow');
bakeOne(markerPath);

svg.querySelectorAll('rect, path, text').forEach(bakeOne);

const clone = svg.cloneNode(true);
clone.removeAttribute('class');
clone.querySelectorAll('[class]').forEach((el) => el.removeAttribute('class'));

const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
defs.appendChild(sprite.querySelector('marker#arrow').cloneNode(true));
defs.appendChild(filterEl.cloneNode(true));
clone.insertBefore(defs, clone.firstChild);

const [, , w, h] = clone.getAttribute('viewBox').split(' ');
clone.setAttribute('width', w);
clone.setAttribute('height', h);

return new XMLSerializer().serializeToString(clone);
}, BAKED_PROPS);

if (markup) {
const file = path.join(OUTPUT_DIR, `pattern-${slugify(names[i])}.svg`);
fs.writeFileSync(file, `<?xml version="1.0" encoding="UTF-8"?>\n${markup}\n`);
console.log(`Saved ${file}`);
} else {
console.warn(`Could not expand card ${i} (${names[i]}), skipping`);
}

await page.evaluate((idx) => toggleCard(idx), i);
}

await browser.close();
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
Loading