composing-html
Composes single-file HTML artifacts (PR review writeups, status reports, incident postmortems, slide decks, design systems, prototypes, flowcharts, module maps, feature explainers, kanban boards, prompt tuners) from a small JSON spec instead of hand-written HTML/CSS/JS. Use when
Install
npx skills add https://github.com/oaustegard/claude-skills/tree/main/plugins/development-tools/skills/composing-html
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oaustegard-claude-skills@llmmart
git clone https://github.com/oaustegard/claude-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole oaustegard/claude-skills collection as a plugin from our marketplace. Git is the plain clone.
README
composing-html
Compose single-file HTML artifacts without hand-writing page chrome. The composer supplies <!DOCTYPE>, <head>, inlined CSS, base.js, design tokens, masthead, and colophon. You supply the body.
The artifact is one file, no CDN, no build step, no server — the agent hands it over and it just opens.
Inspiration
Thariq Shihipar's The Unreasonable Effectiveness of HTML on the Anthropic blog argues HTML is the better artifact format than markdown for agent output: it carries tables, CSS, SVG, embedded code, live interactions, spatial layout, and images, and a single file is shareable and interactive in ways a markdown plan isn't. People will open and read an HTML file they wouldn't read as a hundred-line markdown plan.
The worry near the end of that piece is real, though: ask an agent for HTML and you tend to get either tasteful but generic Claude-aesthetic boilerplate, or an over-engineered SPA. This skill is the narrow defense — a fixed, opinionated chrome (typography, color tokens, layout primitives, base interactions) so the agent spends its output budget on the content, not on re-deriving what a card or a badge looks like for the hundredth time.
For the longer rationale, see The unreasonable effectiveness of HTML, and the skill that tries not to ruin it on muninn.austegard.com.
See SKILL.md for the full inventory and workflow. See CHANGELOG.md for version history.
Two workflows
freeform — the default
Most artifacts. One slot — body_html — for the page body. Use --set body_html=@body.html so multi-line HTML, quotes, and &/< characters don't fight JSON-string escaping:
python scripts/build.py build freeform \
--set title='My Page' \
--set body_html=@body.html \
--out artifact.html
--set KEY=VALUE assigns a literal string; --set KEY=@FILE loads file contents verbatim. Repeatable across all spec fields.
templates — when the shape repeats
Twenty-one templates for recurring artifact shapes: PR reviews, status reports, incident postmortems, slide decks, design systems, flowcharts, kanban boards, prompt tuners, and more. Each has typed slots the template reasons over (pr_review.findings[].severity, slide_deck.slides[].kind).
python scripts/build.py list # all templates
python scripts/build.py describe pr_review # required keys + JSON skeleton
python scripts/build.py build pr_review --spec spec.json --out review.html
Reach for a template only when the same artifact shape recurs across artifacts (same status report week after week, PR reviews across many PRs). For one-offs, freeform is less friction.
What you get for free
| Layer | What it covers |
|---|---|
| Tokens | Color (--clay, --slate, --ivory, --ok/warn/err/info, gray ramp), typography (--serif, --sans, --mono), geometry (radii, borders, shadows) |
| Layout | .page / .page--wide / .page--narrow, .grid--2|3|4, .stack, .row, .card, .rule |
| Components | .eyebrow, .badge--{ok,warn,err,info,clay}, .kbd, .bullets, styled <code> / <pre><code> / <details> |
| Interactions | Tabs, drag-to-reorder (with cross-zone drops), live parameter bindings (--bind-* CSS variables driven by <input> + data-bind), auto copy buttons on every code block |
| Chrome | DOCTYPE, head, masthead (eyebrow + <h1> + subtitle), inlined CSS + JS, colophon |
Everything is inlined into the output file. Nothing fetches at view time.
When to reach for it
- "Compare options side-by-side" →
exploration.comparison_grid - HTML version of a report or review →
report.status_reportorreview.pr_review - "Make me a deck" →
deck.slide_deck(arrow-key + space navigation) - Flowchart, module map, design system reference →
diagram.*,review.module_map,design.design_system - Prototype with live-tunable parameters →
prototype.animation_sandbox - "An HTML artifact" / "a single self-contained HTML file" →
freeform
When to skip
Ad-hoc HTML snippets that don't need page chrome — forms emailed inline, widgets embedded in someone else's page, three-line examples in a chat reply. The skill is the chrome; if you don't need the chrome, you don't need the skill.
Pitfall to avoid
Inlining multi-line HTML into a JSON heredoc:
# ❌ This produces invalid JSON — strings can't contain raw newlines or unescaped quotes
cat > spec.json <<EOF
{ "body_html": "<section>
<h2>Multi-line</h2>
</section>" }
EOF
Use --set body_html=@body.html instead, or assemble the spec in Python with json.dump(spec, f) so escaping is automatic.
Tests
python tests/test_smoke.py # no pytest required
python -m pytest tests -q # with pytest
Covers every template with a representative spec plus explicit security regressions (HTML escaping, script-tag breakout, attribute injection, CSS-color injection, spec mutation) and the --set CLI paths.
Skill manifest
composing-html
Produce single-file HTML artifacts without hand-writing the page chrome. The
composer supplies <!DOCTYPE>, <head>, inlined CSS, base.js, design
tokens, masthead, and colophon. You supply a title and the body content.
The product is the chrome and inventory below — primitives you can drop into any artifact without re-deriving what a card, badge, or eyebrow looks like. Templates are shortcuts on top of this, useful when the same artifact shape repeats; see Templates near the end.
Default workflow: freeform
freeform gives you the whole chrome with one content slot — body_html —
for the page body. Reach for it first. Reach for a template only when the
structure repeats across artifacts (see Templates
near the end).
There are two ways to invoke it. Use the --set flow for anything with a
substantial body — it sidesteps the JSON-string escaping that bites
heredoc-style spec writing (newlines, quotes, </& inside multi-line
HTML).
Recommended: HTML in a file, metadata via --set
1. Write the body to a .html file directly (no JSON, no escaping).
2. python scripts/build.py build freeform \
--set title='My Page' \
--set subtitle='Optional subhead' \
--set body_html=@body.html \
--out artifact.html
--set KEY=VALUE assigns a literal string; --set KEY=@FILE loads the file
contents verbatim into that spec field. Repeat for any field. Works for
body_html, extra_css, extra_js, eyebrow, page_class, and the same
*_html fields in any other template (summary_html, intro_html,
details_html, …).
Spec-file workflow (best for structured templates)
1. python scripts/build.py describe <template> # required keys + skeleton
2. write spec.json
3. python scripts/build.py build <template> --spec spec.json --out artifact.html
For templates with typed slots (pr_review.findings[], slide_deck.slides[],
status_report.metrics[]), the spec file is the right shape — the template
reasons over the structure. For freeform, the spec is mostly a thin config
wrapper around one HTML string; the --set flow above is usually less
friction.
You can mix both: small spec.json for metadata, --set body_html=@body.html
for the heavy bit. --set overrides any matching field from --spec.
Pitfall: don't inline multi-line HTML into a JSON heredoc
cat > spec.json <<EOF { "body_html": "<multi\nline>\n..." } EOF does not
produce valid JSON — JSON strings can't contain raw newlines or unescaped
quotes. Either:
- use
--set body_html=@body.html(recommended), or - assemble the spec in Python with
json.dump(spec, f)so escaping is automatic.
Inventory
Everything in this section is loaded into every artifact via inlined CSS and
base.js. Use these tokens and classes inside body_html (or any
template's *_html field) without re-declaring them.
Color tokens
| Token | Hex | Use |
|---|---|---|
--ivory |
#FAF9F5 |
Page background |
--paper |
#FFFFFF |
Card background |
--slate |
#141413 |
Headings, inverted background |
--clay |
#D97757 |
Brand accent (lines, primary actions) |
--clay-d |
#B85C3E |
Hover/dark variant |
--oat |
#E3DACC |
Soft contrast surface |
--olive |
#788C5D |
Success, secondary accent |
--rust |
#B04A3F |
Errors, destructive |
--moss |
#4A6B3A |
Success text |
--g100 … --g700 |
grays | Surfaces, borders, body text |
Semantic aliases: --ok, --warn, --err, --info.
Type stacks
--serif— display headings (h1, h2, big numerics).--sans— body text (default).--mono— code, eyebrows, badges, captions.
Geometry
--radius-sm (6px) · --radius (10px) · --radius-lg (16px) ·
--border · --border-soft · --shadow-card · --shadow-pop.
Layout primitives
.page— main column (1080px max). Variants:.page--wide(1280px),.page--narrow(720px). Set via thepage_classspec key..masthead— header strip with.eyebrow+<h1>+.subtitle(auto-rendered fromtitle/subtitle/eyebrowunlessshow_mastheadis false)..grid .grid--2|3|4|auto— responsive CSS grid..stack,.row— vertical / horizontal flex..card,.card--soft,.card--elev— content containers..rule—<hr>underline below<h2>..colophon— optional footer strip; passcolophon="text"topage()to show it (off by default).
Components
- Eyebrow:
<div class="eyebrow">SECTION</div>— small all-caps label with a leading clay rule. - Badge:
<span class="badge badge--ok|warn|err|info|clay">v1.0</span>. - Kbd:
<span class="kbd">⌘K</span>. - Bullets:
<ul class="bullets"><li>…</li></ul>— clay dots. - Code: inline
<code>and block<pre><code>. Block code gets acopybutton automatically viabase.js. - Details: native
<details><summary>…</summary>…</details>styled.
Tabs
<div class="tabgroup">
<div class="tabs">
<button data-target="a">Tab A</button>
<button data-target="b">Tab B</button>
</div>
<div class="tab-panel" data-id="a">…</div>
<div class="tab-panel" data-id="b">…</div>
</div>
base.js wires this automatically and selects the first tab by default.
Drag-to-reorder
<div data-sortable="true">
<div draggable="true">…</div>
<div draggable="true">…</div>
</div>
Optional cross-zone drops: add data-zone="<id>" to each container.
Live parameter bindings
<input type="range" data-bind="size" min="0" max="100" value="50" data-format="number" data-unit="px">
<span data-out="size"></span>
<style>.box { width: var(--bind-size, 50px); }</style>
The CSS custom property --bind-<name> is updated on every input event,
and any [data-out="<name>"] element receives the formatted value.
Output rules
Spend output tokens on content, not chrome:
- Never write
<html>,<head>,<style>,<script>, or<link>. The composer adds all of them. If you find yourself writing a complete page, you missed the skill. - Don't restate design tokens. Reuse the inventory above —
var(--clay),.card,.badge--warn,.bullets, etc. are already loaded. Don't hardcode hex/rgb()colours, inlinefont-family/font-size, or reference tokens that aren't in the palette. body_htmlis HTML, not a JSON dialect. Write<section>,<h2>,<ul class="bullets">directly. No translation layer.- Anything in an
_htmlfield is inserted verbatim — escape any user-supplied content yourself. All other string values are HTML-escaped automatically. - One artifact per build. Browser tabs are free.
Checking output
After building, lint the artifact before presenting it:
python scripts/build.py check artifact.html
The checker is deterministic — no model call, stdlib only. It doesn't grade
taste (the fixed chrome already prevents the usual AI tells); it flags content
that breaks out of the design system or wires base.js hooks to nothing —
the failure modes the chrome can't prevent on its own:
| rule | catches | severity |
|---|---|---|
chrome-leak |
<html>/<head>/<link> (and top-level <style>/<script>) in body_html |
error |
undefined-token |
var(--typo) — a token not in the palette or declared here |
error |
broken-tabs |
data-target with no matching .tab-panel[data-id] |
error |
hardcoded-color |
#hex / rgb() literals instead of palette tokens |
warn |
inline-typography |
font-family / font-size overriding the type stacks |
warn |
undefined-token for --bind-* |
(allowed — created by data-bind) |
— |
nested-card |
.card inside .card |
warn |
broken-bind |
data-bind with no consumer, or orphan data-out |
warn |
broken-sortable |
data-sortable with no draggable children |
warn |
heading-skip |
heading levels that jump (h1 → h3) | warn |
img-no-alt |
<img> without an alt attribute |
warn |
Exit code is non-zero when any error-severity rule fires. The output rules
above carry <!-- rule:ID --> anchors tying each guidance line to its check,
so the teaching and the enforcement stay in sync. Full-artifact vs body
fragment is auto-detected; force with --full / --fragment. --json emits
machine-readable findings. Contrast ratios are intentionally not checked — the
token pairs are pre-vetted and regex can't judge author-introduced pairs
without false positives.
Iteration
Edit the spec, re-run build, open in a browser. If a layout pattern
repeats across multiple artifacts, that's when a template earns its
keep — otherwise stay in freeform.
Templates: shortcuts for repeat structure
When the same artifact shape recurs (status reports week after week, PR reviews across many PRs, slide decks with consistent navigation), a template's fixed slot map is worth the translation cost. It enforces cross-artifact consistency and skips the layout decisions you'd otherwise re-derive each time.
Use a template only when:
- You're producing the same artifact shape repeatedly.
- The repeat structure justifies a fixed slot map.
- Cross-artifact consistency matters more than per-artifact flexibility.
Otherwise: freeform.
1. python scripts/build.py list # all templates, one-line summaries
2. python scripts/build.py describe <template> # required keys + JSON skeleton
3. write spec.json # only your content + parameters
4. python scripts/build.py build <template> --spec spec.json --out artifact.html
describe prints a valid-JSON starter skeleton you can edit in place. For
worked examples, see references/templates.md — but only after picking a
template; reading it cold wastes context.
For templates with prose-heavy *_html slots (e.g. summary_html,
intro_html, details_html), the same --set KEY=@FILE mechanism from the
freeform workflow applies — load the prose from a .html file rather than
escaping it into the JSON spec.
There are 21 templates, grouped into 9 categories plus freeform:
report.*— status_report, incident_reportreview.*— pr_review, code_walkthrough, module_mapeditor.*— triage_board, flag_editor, prompt_tunerdeck.*— slide_deck (arrow-key + space navigation)design.*— design_system, component_variantsexploration.*— comparison_grid, design_directions, implementation_planresearch.*— feature_explainer, concept_explainerdiagram.*— svg_figure_sheet, flowchartprototype.*— animation_sandbox, click_flow
Some templates with prose-heavy slots take raw HTML in keys ending with
_html (e.g. summary_html, intro_html, details_html). Same rules as
freeform.body_html: use the inventory above, escape user-supplied content.
Tests
tests/test_smoke.py covers every template with a representative spec plus
explicit security regressions (table escaping, script-tag breakout in
prompt_tuner, attribute injection in flag_editor, CSS-color injection,
spec mutation in module_map). tests/test_checker.py covers the check
linter — one assertion per rule (fires on the violation, silent on the clean
case). Run with:
python composing-html/tests/test_smoke.py # no pytest required
python composing-html/tests/test_checker.py # no pytest required
python -m pytest composing-html/tests -q # if pytest is available
When adding or changing a template, add a spec entry and any regression
asserts before merging. When adding a checker rule, add it to both
scripts/checker.py and a <!-- rule:ID --> anchor in the relevant guidance
line, plus a test assertion.
Files (claude-skills)
-
assets
-
base.css 9 KB · in bundle
-
base.js 6.3 KB
/* ========================================================================= composing-html / base.js Tiny vanilla helpers. No framework. Activated only if the markup needs it. ========================================================================= */ (function () { 'use strict'; // Copy-to-clipboard for <pre><code> blocks marked .code-wrap or any pre. function initCopyButtons() { document.querySelectorAll('pre').forEach(function (pre) { if (pre.dataset.copyInit) return; pre.dataset.copyInit = '1'; var wrap = pre.parentElement; if (!wrap || !wrap.classList.contains('code-wrap')) { wrap = document.createElement('div'); wrap.className = 'code-wrap'; pre.parentNode.insertBefore(wrap, pre); wrap.appendChild(pre); } var btn = document.createElement('button'); btn.className = 'copy-btn'; btn.type = 'button'; btn.textContent = 'copy'; btn.addEventListener('click', function () { var text = pre.innerText; navigator.clipboard.writeText(text).then(function () { btn.textContent = 'copied'; setTimeout(function () { btn.textContent = 'copy'; }, 1400); }).catch(function () { btn.textContent = 'err'; }); }); wrap.appendChild(btn); }); } // Tab groups: <div class="tabgroup"><div class="tabs"><button data-target="x">..</button></div> // <div class="tab-panel" data-id="x">..</div></div> function initTabs() { document.querySelectorAll('.tabgroup').forEach(function (group) { var buttons = group.querySelectorAll('.tabs button[data-target]'); var panels = group.querySelectorAll('.tab-panel[data-id]'); function show(id) { buttons.forEach(function (b) { b.setAttribute('aria-selected', b.dataset.target === id ? 'true' : 'false'); }); panels.forEach(function (p) { p.dataset.active = p.dataset.id === id ? 'true' : 'false'; }); } buttons.forEach(function (b) { b.addEventListener('click', function () { show(b.dataset.target); }); }); var first = buttons[0]; if (first) show(first.dataset.target); }); } // Slide deck: arrow-key & space navigation between .slide elements. // Activated only when document.body has data-deck="true". function initDeck() { if (document.body.dataset.deck !== 'true') return; var slides = Array.prototype.slice.call(document.querySelectorAll('.slide')); if (!slides.length) return; function currentIndex() { var mid = window.innerHeight / 2; for (var i = 0; i < slides.length; i++) { var r = slides[i].getBoundingClientRect(); if (r.top <= mid && r.bottom > mid) return i; } return 0; } function go(delta) { var i = Math.max(0, Math.min(slides.length - 1, currentIndex() + delta)); slides[i].scrollIntoView({ behavior: 'smooth', block: 'start' }); } document.addEventListener('keydown', function (e) { if (e.target && /^(input|textarea|select)$/i.test(e.target.tagName)) return; if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); go(+1); } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); go(-1); } else if (e.key === 'Home') { e.preventDefault(); slides[0].scrollIntoView({ behavior: 'smooth' }); } else if (e.key === 'End') { e.preventDefault(); slides[slides.length - 1].scrollIntoView({ behavior: 'smooth' }); } }); } // Drag-to-reorder for editor-style triage boards. // Markup: a container with data-sortable="true" and direct children with [draggable="true"]. // Cross-zone moves work because `dragged` is module-scoped, not per-zone. var dragged = null; function initSortable() { document.querySelectorAll('[data-sortable="true"]').forEach(function (zone) { if (zone.dataset.sortInit) return; zone.dataset.sortInit = '1'; zone.addEventListener('dragstart', function (e) { var t = e.target.closest('[draggable="true"]'); if (!t || t.parentElement !== zone) return; dragged = t; t.style.opacity = '0.4'; try { e.dataTransfer.effectAllowed = 'move'; } catch (_) {} }); zone.addEventListener('dragend', function () { if (dragged) dragged.style.opacity = ''; dragged = null; }); zone.addEventListener('dragover', function (e) { if (!dragged) return; e.preventDefault(); try { e.dataTransfer.dropEffect = 'move'; } catch (_) {} var after = null; var children = Array.prototype.slice.call(zone.children) .filter(function (c) { return c !== dragged && c.draggable; }); for (var i = 0; i < children.length; i++) { var box = children[i].getBoundingClientRect(); if (e.clientY < box.top + box.height / 2) { after = children[i]; break; } } if (after) zone.insertBefore(dragged, after); else if (dragged.parentElement !== zone || zone.lastElementChild !== dragged) zone.appendChild(dragged); }); zone.addEventListener('drop', function (e) { if (dragged) e.preventDefault(); }); }); } // Live param controls: any [data-bind] input updates the textContent of [data-out="<name>"]. // Optional: [data-format="number|percent|ms"]. function initBindings() { var fmt = { number: function (v) { return Number(v).toString(); }, percent: function (v) { return (Number(v) * 100).toFixed(0) + '%'; }, ms: function (v) { return Number(v) + 'ms'; } }; document.querySelectorAll('input[data-bind], select[data-bind]').forEach(function (el) { function push() { var name = el.dataset.bind; var f = fmt[el.dataset.format] || function (v) { return v; }; document.querySelectorAll('[data-out="' + name + '"]').forEach(function (o) { o.textContent = f(el.value); }); // CSS-var bridge: --bind-<name> document.documentElement.style.setProperty('--bind-' + name, el.value + (el.dataset.unit || '')); } el.addEventListener('input', push); push(); }); } function ready(fn) { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); } ready(function () { initCopyButtons(); initTabs(); initDeck(); initSortable(); initBindings(); }); })();
-
-
references
-
palette.md 3.4 KB
# Palette and primitives The base CSS is inlined into every artifact. You can use these tokens and classes inside any `*_html` field — including `freeform.body_html` — without re-declaring them. ## Color tokens | Token | Hex | Use | |---|---|---| | `--ivory` | `#FAF9F5` | Page background | | `--paper` | `#FFFFFF` | Card background | | `--slate` | `#141413` | Headings, inverted background | | `--clay` | `#D97757` | Brand accent (lines, primary actions) | | `--clay-d` | `#B85C3E` | Hover/dark variant | | `--oat` | `#E3DACC` | Soft contrast surface | | `--olive` | `#788C5D` | Success, secondary accent | | `--rust` | `#B04A3F` | Errors, destructive | | `--moss` | `#4A6B3A` | Success text | | `--g100` … `--g700` | grays | Surfaces, borders, body text | Semantic aliases: `--ok`, `--warn`, `--err`, `--info`. ## Type stacks - `--serif` — display headings (h1, h2, big numerics). - `--sans` — body text (default). - `--mono` — code, eyebrows, badges, captions. ## Geometry `--radius-sm` (6px) · `--radius` (10px) · `--radius-lg` (16px) · `--border` · `--border-soft` · `--shadow-card` · `--shadow-pop`. ## Layout primitives - `.page` — main column (1080px max). Variants: `.page--wide` (1280px), `.page--narrow` (720px). - `.masthead` — header strip with `.eyebrow` + `<h1>` + `.subtitle`. - `.grid .grid--2|3|4|auto` — responsive CSS grid. - `.stack`, `.row` — vertical / horizontal flex. - `.card`, `.card--soft`, `.card--elev` — content containers. - `.rule` — `<hr>` underline below `<h2>`. - `.colophon` — optional footer strip; pass `colophon="text"` to `page()` to show it (off by default). ## Components - **Eyebrow**: `<div class="eyebrow">SECTION</div>` — small all-caps label with a leading clay rule. - **Badge**: `<span class="badge badge--ok|warn|err|info|clay">v1.0</span>`. - **Kbd**: `<span class="kbd">⌘K</span>`. - **Bullets**: `<ul class="bullets"><li>…</li></ul>` — clay dots. - **Code**: inline `<code>` and block `<pre><code>`. Block code gets a `copy` button automatically via `base.js`. - **Details**: native `<details><summary>…</summary>…</details>` styled. ## Tabs ```html <div class="tabgroup"> <div class="tabs"> <button data-target="a">Tab A</button> <button data-target="b">Tab B</button> </div> <div class="tab-panel" data-id="a">…</div> <div class="tab-panel" data-id="b">…</div> </div> ``` `base.js` wires this automatically and selects the first tab by default. ## Drag-to-reorder ```html <div data-sortable="true"> <div draggable="true">…</div> <div draggable="true">…</div> </div> ``` Optional cross-zone drops: add `data-zone="<id>"` to each container. ## Live parameter bindings ```html <input type="range" data-bind="size" min="0" max="100" value="50" data-format="number" data-unit="px"> <span data-out="size"></span> <style>.box { width: var(--bind-size, 50px); }</style> ``` The CSS custom property `--bind-<name>` is updated on every input event, and any `[data-out="<name>"]` element receives the formatted value. ## Helper functions (Python side) If you're calling templates from a custom builder, `composer.py` exposes: `esc`, `attrs`, `tag`, `void`, `eyebrow`, `badge`, `kbd`, `code_block`, `inline_code`, `section`, `card`, `grid`, `stack`, `row`, `bullets`, `kv_list`, `table`, `details`, `tabs`, `callout`, `slug`. These all return HTML strings and accept plain Python data. -
templates.md 13.2 KB
# Template reference Detailed parameter spec for each template. Read **only the section you need**; each template's `describe` output covers the same ground in CLI form. For all templates: spec keys ending in `_html` are inserted verbatim — escape your own content there if it contains user input. All other string values are HTML-escaped automatically. --- ## exploration.comparison_grid ```json { "title": "Three caching approaches", "subtitle": "Tradeoffs across latency, memory, complexity", "eyebrow": "EXPLORATION", "recommendation": "In-memory LRU", "options": [ { "name": "In-memory LRU", "verdict": "Simplest, lowest latency", "summary": "Per-process bounded cache.", "pros": ["Sub-µs hits", "No external dep"], "cons": ["Per-process duplication", "Cold on restart"], "tags": ["latency:fast", "cost:low"] } ] } ``` `recommendation` (optional): exact `name` of the option to highlight with a double border + clay accent + `RECOMMENDED` badge. Up to 3 options render side-by-side; more wrap to additional rows. --- ## exploration.design_directions ```json { "title": "Brand directions", "directions": [ { "name": "Stoneware", "vibe": "Warm, grounded, hand-thrown", "palette": ["#FAF9F5", "#D97757", "#3D3D3A", "#788C5D"], "typography": {"display": "Aa", "body": "The quick brown fox..."}, "notes": "Pairs with serif display + sans body." } ] } ``` Each direction renders as a card with palette swatches, a sample type block, and notes. --- ## exploration.implementation_plan ```json { "title": "Search v2 rollout", "summary_html": "<p>Cutover from BM25 to hybrid.</p>", "data_flow": ["Query", "Embed", "Hybrid score", "Rerank", "Response"], "milestones": [ {"name": "Embedding pipeline", "when": "Wk 1", "owner": "alex", "deliverables": ["Tokenizer", "Batcher"], "status": "done"}, {"name": "Hybrid scorer", "when": "Wk 2", "deliverables": ["RRF fusion"], "status": "active"} ], "risks": [ {"risk": "Latency spike", "likelihood": "med", "impact": "high", "mitigation": "Pre-warm + canary."} ] } ``` `status`: `done | active | next | later` controls the dot color in the timeline. `data_flow` is a simple list of step names rendered as arrow-connected boxes. --- ## review.pr_review ```json { "title": "PR #247 — Caching layer", "pr": {"repo": "octo/svc", "number": 247, "branch": "feat/cache", "author": "@jess", "additions": 312, "deletions": 48}, "verdict": "Approve with minor follow-ups", "summary_html": "<p>Adds in-memory LRU on the search hot path.</p>", "findings": [ { "file": "src/search/cache.py", "line": 42, "severity": "warn", "title": "LRU max size hardcoded", "body_html": "<p>Pull from config to allow per-env tuning.</p>", "snippet": "CACHE_MAX = 1024", "snippet_lang": "python" } ] } ``` `severity`: `info | nit | warn | block`. --- ## review.code_walkthrough ```json { "title": "How retrieval works", "intro_html": "<p>The retrieval path is three stages.</p>", "steps": [ {"heading": "Tokenize", "body_html": "<p>Whitespace + lowercasing.</p>", "code": "tokens = text.lower().split()", "lang": "python"} ] } ``` Steps are rendered as numbered (01, 02, …) two-column rows. Narrow page width. --- ## review.module_map ```json { "title": "Service map", "intro_html": "<p>Top-level packages.</p>", "nodes": [ {"id": "api", "label": "api", "description": "HTTP handlers", "kind": "core"}, {"id": "engine", "label": "engine", "description": "ranking", "kind": "core"}, {"id": "cache", "label": "cache", "description": "LRU", "kind": "util"}, {"id": "db", "label": "postgres", "description": "index store", "kind": "external"} ], "edges": [ {"from": "api", "to": "engine"}, {"from": "engine", "to": "cache", "label": "hot path"} ], "legend": [{"label": "core", "kind": "core"}, {"label": "util", "kind": "util"}] } ``` `kind`: `core | util | external` controls fill/border. `x` and `y` are optional — auto-laid in a 3-column grid if omitted. --- ## design.design_system ```json { "title": "Birchline design tokens", "colors": { "Brand": [{"name": "Clay", "hex": "#D97757", "token": "--clay"}], "Neutral": [{"name": "Slate", "hex": "#141413", "token": "--slate"}] }, "typography": [ {"name": "Display", "font_family": "var(--serif)", "size": "48px", "weight": "500", "line_height": "1.1"}, {"name": "Body", "font_family": "var(--sans)", "size": "16px", "weight": "430"} ], "spacing": [{"name": "xs", "px": 4}, {"name": "sm", "px": 8}, {"name": "md", "px": 16}], "radii": [{"name": "sm", "px": 4}, {"name": "md", "px": 10}, {"name": "lg", "px": 16}] } ``` Any group of colors is allowed — keys become section labels. --- ## design.component_variants ```json { "title": "Button variants", "components": [ { "name": "Button", "description": "Sizes × intents.", "axes": { "Size": ["sm", "md", "lg"], "Intent": ["default", "primary", "danger"] }, "cells": [ {"coords": {"Size": "sm", "Intent": "default"}, "html": "<button>Click</button>"}, {"coords": {"Size": "md", "Intent": "primary"}, "html": "<button style='background:var(--clay);color:white;border:none;padding:8px 16px;border-radius:6px;'>Click</button>"} ] } ] } ``` With ≥2 axes, cells render in a labelled table. With 1 axis, cells render in an auto grid of cards. --- ## prototype.animation_sandbox ```json { "title": "Easing playground", "params": [ {"name": "duration", "label": "Duration", "type": "range", "min": 100, "max": 2000, "step": 50, "default": 600, "unit": "ms", "format": "ms"}, {"name": "easing", "label": "Easing", "type": "select", "options": ["ease", "ease-in-out", "linear", "cubic-bezier(.2,.8,.2,1)"], "default": "ease-in-out"} ], "preview_html": "<div id='ball' style='width:60px;height:60px;background:var(--clay);border-radius:50%;animation:bounce var(--bind-duration,600ms) var(--bind-easing,ease) infinite alternate;'></div><style>@keyframes bounce{from{transform:translateX(0)}to{transform:translateX(200px)}}</style>", "preview_height": "240px" } ``` Each `param`'s value lands in the CSS custom property `--bind-<name>` (append `unit` to the value) and is mirrored to any element with `data-out="<name>"`. --- ## prototype.click_flow ```json { "title": "Onboarding flow", "screens": [ {"id": "welcome", "label": "Welcome", "html": "<h2>Welcome</h2><p>...</p>", "transitions": [{"label": "Get started →", "to_id": "signup"}]}, {"id": "signup", "label": "Sign up", "html": "<form>...</form>", "transitions": [{"label": "Submit →", "to_id": "done"}]}, {"id": "done", "label": "Done", "html": "<p>You're in.</p>"} ] } ``` Transitions are anchor links; clicking jumps to the target screen. --- ## diagram.svg_figure_sheet ```json { "title": "Architecture figures", "figures": [ {"label": "Fig. 1", "caption": "Request flow", "svg": "<svg viewBox='0 0 200 100'>...</svg>", "span": 1} ] } ``` `span: 2` makes a figure occupy the full row in the 2-column grid. --- ## diagram.flowchart ```json { "title": "Deploy pipeline", "orientation": "vertical", "steps": [ {"id": "build", "label": "Build", "kind": "start"}, {"id": "test", "label": "Tests pass?", "kind": "decision", "branches": [{"label": "yes", "to_id": "stage"}, {"label": "no", "to_id": "fail"}]}, {"id": "stage", "label": "Deploy staging", "kind": "process", "details_html": "<p>Blue/green swap.</p>"}, {"id": "prod", "label": "Deploy prod", "kind": "end"}, {"id": "fail", "label": "Notify", "kind": "end"} ] } ``` `kind`: `start | process | decision | end`. Steps with `details_html` get collapsible expanders below the diagram. --- ## deck.slide_deck ```json { "title": "Q2 review", "slides": [ {"kind": "title", "title": "Q2 Roundup", "subtitle": "What shipped", "eyebrow": "PLATFORM", "byline": "may 9 · alex"}, {"kind": "section", "title": "Shipped", "eyebrow": "PART ONE", "invert": true}, {"kind": "content", "title": "Search", "body_html": "<ul class='bullets'><li>...</li></ul>"}, {"kind": "quote", "quote": "Ship to learn.", "attribution": "team retro"}, {"kind": "code", "title": "API", "code": "client.search(...)", "lang": "python"}, {"kind": "image", "src": "https://...", "alt": "...", "caption": "..."} ] } ``` If no `kind: title` slide exists, one is auto-prepended from `title` / `subtitle`. Navigation: arrow keys + space + Page Up/Down + Home/End. --- ## research.feature_explainer ```json { "title": "Streaming responses", "intro_html": "<p>How tokens flow.</p>", "sections": [ { "id": "transport", "heading": "Transport", "body_html": "<p>SSE over HTTP/2.</p>", "code_tabs": [ {"label": "JS", "code": "const r = await fetch(url);", "lang": "javascript"}, {"label": "Python", "code": "with httpx.stream(...): ...", "lang": "python"} ] } ] } ``` A sticky TOC links to each section by `id`. Tabs let you compare code samples across languages without scroll. --- ## research.concept_explainer ```json { "title": "Vector embeddings", "body_html": "<p>An embedding is...</p><h2>Distance</h2><p>Cosine similarity...</p>", "demo_html": "<div>...interactive demo region...</div>", "glossary": [ {"term": "Cosine sim.", "definition_html": "<code>1 - cos(θ)</code>"} ] } ``` Narrow page width. The `demo_html` slot is rendered inside an elevated card. --- ## report.status_report ```json { "title": "Platform — Week of May 4", "subtitle": "Search GA, queue migration in flight", "metrics": [ {"label": "Deploys", "value": "42", "delta": "+12 vs prev", "kind": "ok"}, {"label": "P95", "value": "180ms", "delta": "-40ms", "kind": "ok"}, {"label": "Open Sev2s", "value": "3", "delta": "+1", "kind": "warn"} ], "shipped": [{"title": "Search GA", "owner": "alex", "body_html": "<p>Public launch.</p>"}], "in_flight":[{"title": "Queue migration", "owner": "jess", "progress": 0.6, "body_html": "<p>Cutover Friday.</p>"}], "blocked": [{"title": "OAuth review", "owner": "sec-team", "body_html": "<p>Awaiting compliance.</p>"}] } ``` `progress` is 0–1; rendered as a clay-filled bar with a percentage label. --- ## report.incident_report ```json { "title": "INC-1247 — Search outage", "severity": "sev2", "duration": "47m", "impact_html": "<p>~12% of searches returned empty results.</p>", "summary_html": "<p>Index shard rebalance starved the read pool.</p>", "timeline": [ {"at": "14:02", "event": "Spike in 5xx on /search", "kind": "detected"}, {"at": "14:08", "event": "Paged on-call", "kind": "triage"}, {"at": "14:32", "event": "Rolled back rebalance", "kind": "mitigation"}, {"at": "14:49", "event": "Error rate normal", "kind": "resolved"} ], "root_cause_html": "<p>The rebalance scheduler had no concurrency cap.</p>", "followups": [ {"title": "Cap rebalance concurrency", "owner": "platform", "due": "May 16", "status": "open"} ] } ``` `severity`: `sev1 | sev2 | sev3 | sev4`. --- ## editor.triage_board ```json { "title": "Triage", "columns": [ {"id": "todo", "label": "To do", "color": "info", "items": [{"id": "i1", "title": "Fix auth bug", "tags": ["P1", "bug"]}]}, {"id": "doing", "label": "Doing", "color": "clay", "items": [{"id": "i2", "title": "Refactor cache", "tags": ["P2"]}]}, {"id": "done", "label": "Done", "color": "olive", "items": [{"id": "i3", "title": "Deploy v0.4"}]} ] } ``` Cards are draggable within and across columns. `color`: `clay | olive | info | err`. --- ## editor.flag_editor ```json { "title": "Feature flags", "flags": [ {"id": "new_search", "label": "New search", "description": "Vector + BM25 hybrid.", "default": true}, {"id": "reranker", "label": "Cross-encoder reranker", "description": "Cost: ~30ms.", "default": false, "requires": ["new_search"]}, {"id": "legacy_search", "label": "Legacy search", "default": false, "conflicts_with": ["new_search"]} ] } ``` When a flag is toggled on, missing `requires` and conflicting `conflicts_with` flags surface as inline warnings. --- ## editor.prompt_tuner ```json { "title": "Email subject tuner", "template": "Hi {{name}}, your {{item}} is {{status}}.", "variables": [ {"name": "name", "label": "Recipient", "default": "Alex"}, {"name": "item", "label": "Item", "type": "textarea", "default": "order #1234"}, {"name": "status", "type": "select", "options": ["shipped", "delayed", "delivered"], "default": "shipped"} ] } ``` Edit any variable; the rendered prompt updates live. `type`: `text` (default) | `textarea` | `select`. --- ## freeform ```json { "title": "Refactor proposal", "subtitle": "Extract OAuth into its own module", "eyebrow": "ARCHITECTURE", "body_html": "<section><h2>Why</h2><hr class='rule'><p>...</p></section>", "page_class": "page", "show_masthead": true } ``` `page_class`: `page` | `page page--wide` | `page page--narrow`. `extra_css` and `extra_js` accept raw strings appended after base styles. `body_attrs`: dict on `<body>` (e.g. `{"data-deck": "true"}` to enable arrow-key deck navigation on a custom layout).
-
-
scripts
-
templates
-
deck.py 4.5 KB
"""Slide deck template — arrow-key navigable single-file presentation. - deck.slide_deck: Title slide + content slides + optional code/quote slides. """ from __future__ import annotations import composer as c from . import register _SLIDE_KINDS = {"title", "section", "content", "quote", "code", "image"} def _render_slide(s: dict) -> str: kind = s.get("kind", "content") if kind not in _SLIDE_KINDS: raise ValueError( f"deck.slide_deck: unknown slide kind {kind!r}. " f"Valid kinds: {sorted(_SLIDE_KINDS)}." ) invert_class = " invert" if s.get("invert") else "" inner = "" if kind == "title": eb = f'<div class="eyebrow">{c.esc(s.get("eyebrow",""))}</div>' if s.get("eyebrow") else "" sub = f'<p class="subtitle">{c.esc(s.get("subtitle",""))}</p>' if s.get("subtitle") else "" byl = f'<div class="byline">{c.esc(s.get("byline",""))}</div>' if s.get("byline") else "" inner = f'{eb}<h1>{c.esc(s.get("title",""))}</h1>{sub}{byl}' elif kind == "section": inner = f'<div class="eyebrow">{c.esc(s.get("eyebrow","SECTION"))}</div><h1>{c.esc(s.get("title",""))}</h1>' elif kind == "quote": attr = f'<div class="byline">— {c.esc(s.get("attribution",""))}</div>' if s.get("attribution") else "" inner = f'<blockquote style="font-family:var(--serif);font-size:34px;line-height:1.2;border-left:3px solid var(--clay);padding-left:24px;">{c.esc(s.get("quote",""))}</blockquote>{attr}' elif kind == "code": eb = f'<div class="eyebrow">{c.esc(s.get("eyebrow",""))}</div>' if s.get("eyebrow") else "" h = f'<h2>{c.esc(s.get("title",""))}</h2>' if s.get("title") else "" inner = f'{eb}{h}{c.code_block(s.get("code",""), lang=s.get("lang"))}' elif kind == "image": cap = f'<div style="font-size:13px;color:var(--g500);margin-top:10px;text-align:center;">{c.esc(s.get("caption",""))}</div>' if s.get("caption") else "" inner = f'<img src="{c.esc(s.get("src",""))}" alt="{c.esc(s.get("alt",""))}" style="max-height:60vh;margin:0 auto;">{cap}' else: # content eb = f'<div class="eyebrow">{c.esc(s.get("eyebrow",""))}</div>' if s.get("eyebrow") else "" h = f'<h2>{c.esc(s.get("title",""))}</h2>' if s.get("title") else "" inner = f'{eb}{h}{s.get("body_html","")}' return f'<section class="slide{invert_class}"><div class="slide-inner">{inner}</div></section>' @register( "deck.slide_deck", summary="Single-file slide deck with arrow-key/space navigation. Snap-scrolling.", spec_keys={ "title": "Page title (also used as title slide if no title-kind slide present).", "subtitle": "Optional sub-headline used by an auto-title-slide.", "slides": "List[{kind: 'title|section|content|quote|code|image', invert?: bool, ...kind-specific keys}]. " "title:{title,subtitle?,eyebrow?,byline?}. section:{title,eyebrow?}. " "content:{title?,eyebrow?,body_html}. quote:{quote,attribution?}. " "code:{title?,eyebrow?,code,lang?}. image:{src,alt?,caption?}.", }, ) def slide_deck(spec: dict) -> dict: slides = spec.get("slides") or [] # If no title-kind slide, prepend one from title/subtitle. if not any(s.get("kind") == "title" for s in slides): slides = [{"kind": "title", "title": spec.get("title", "Untitled"), "subtitle": spec.get("subtitle", "")}] + slides body = "".join(_render_slide(s) for s in slides) extra_css = """ body { scroll-snap-type: y mandatory; overflow-x: hidden; } main.page { max-width: none; padding: 0; } .colophon { display: none; } .slide { width: 100vw; min-height: 100vh; scroll-snap-align: start; scroll-snap-stop: always; display: flex; align-items: center; justify-content: center; padding: 8vh 6vw; } .slide-inner { width: 100%; max-width: 880px; } .slide.invert { background: var(--slate); color: var(--ivory); } .slide.invert .eyebrow { color: var(--g300); } .slide.invert .eyebrow::before { background: var(--clay); } .slide h1 { font-size: clamp(40px, 6vw, 64px); } .slide h2 { font-size: clamp(30px, 4vw, 42px); margin-bottom: 36px; } .slide pre { font-size: 16px; } .byline { margin-top: 40px; font-family: var(--mono); font-size: 12px; color: var(--g500); } """ return { "title": spec.get("title", "Deck"), "subtitle": spec.get("subtitle"), "body": body, "show_masthead": False, "extra_css": extra_css, "body_attrs": {"data-deck": "true"}, } -
design.py 8 KB
"""Design templates. - design.design_system: Color swatches, typography scale, spacing tokens. - design.component_variants: Grid of component states/sizes/intents. """ from __future__ import annotations import composer as c from . import register # --------------------------------------------------------------------------- # # design_system # # --------------------------------------------------------------------------- # @register( "design.design_system", summary="Design system reference: color swatches, type scale, spacing tokens.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "colors": "Dict[group_name -> List[{name, hex, token?}]] — e.g. {'Brand': [{'name':'Clay','hex':'#D97757','token':'--clay'}]}.", "typography": "List[{name, font_family, size, weight?, line_height?, sample?}].", "spacing": "List[{name, px}] — rendered as a horizontal ruler.", "radii": "Optional list[{name, px}].", }, ) def design_system(spec: dict) -> dict: sections = [] colors = spec.get("colors") or {} if colors: groups = [] for group, items in colors.items(): chips = [] for item in items: hex_v = c.css_color(item.get("hex"), default="#000") chips.append( f'<div><div style="width:64px;height:64px;border-radius:8px;background:{hex_v};' f'border:1px solid var(--g200);margin-bottom:6px;"></div>' f'<div style="font-family:var(--mono);font-size:11px;color:var(--g700);">{c.esc(item.get("name"))}</div>' f'<div style="font-family:var(--mono);font-size:11px;color:var(--g500);">{c.esc(hex_v)}</div>' # display value (escaped) + (f'<div style="font-family:var(--mono);font-size:11px;color:var(--g500);">{c.esc(item.get("token"))}</div>' if item.get("token") else "") + '</div>' ) groups.append( f'<div style="margin-bottom:28px;">' f'<div style="font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--g500);margin-bottom:12px;">{c.esc(group)}</div>' f'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:18px;">{"".join(chips)}</div>' f'</div>' ) sections.append(c.section("Color", body="".join(groups))) typography = spec.get("typography") or [] if typography: rows = [] for t in typography: style = (f'font-family:{c.esc(t.get("font_family","var(--sans)"))};' f'font-size:{c.esc(t.get("size","16px"))};' f'line-height:{c.esc(t.get("line_height","1.4"))};' f'font-weight:{c.esc(t.get("weight","400"))};') sample = c.esc(t.get("sample", "The quick brown fox jumps over the lazy dog.")) rows.append( f'<div style="display:flex;justify-content:space-between;align-items:baseline;gap:24px;' f'padding:18px 22px;border-bottom:1px solid var(--g150);">' f'<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;{style}">{sample}</div>' f'<div style="font-family:var(--mono);font-size:12px;color:var(--g500);text-align:right;flex-shrink:0;">' f'<div style="color:var(--g700);">{c.esc(t.get("name"))}</div>' f'<div>{c.esc(t.get("size",""))} / {c.esc(t.get("weight",""))}</div></div></div>' ) sections.append(c.section("Typography", body=f'<div class="card" style="padding:0;overflow:hidden;">{"".join(rows)}</div>')) spacing = spec.get("spacing") or [] if spacing: bars = [] for s in spacing: px = int(s.get("px", 0)) bars.append( f'<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">' f'<div style="background:var(--clay);border-radius:3px;height:14px;width:{max(px,4)}px;"></div>' f'<div style="font-family:var(--mono);font-size:11px;color:var(--g700);text-align:center;">{c.esc(s.get("name"))}' f'<div style="color:var(--g500);">{px}px</div></div></div>' ) sections.append(c.section("Spacing", body=f'<div class="card" style="display:flex;align-items:flex-end;gap:28px;overflow-x:auto;">{"".join(bars)}</div>')) radii = spec.get("radii") or [] if radii: chips = [] for r in radii: px = int(r.get("px", 0)) chips.append( f'<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">' f'<div style="width:64px;height:64px;background:var(--oat);border:1.5px solid var(--clay);border-radius:{px}px;"></div>' f'<div style="font-family:var(--mono);font-size:11px;color:var(--g700);text-align:center;">{c.esc(r.get("name"))}' f'<div style="color:var(--g500);">{px}px</div></div></div>' ) sections.append(c.section("Radius", body=f'<div class="card" style="display:flex;gap:28px;flex-wrap:wrap;">{"".join(chips)}</div>')) return { "title": spec.get("title", "Design system"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "DESIGN SYSTEM"), "body": "".join(sections), } # --------------------------------------------------------------------------- # # component_variants # # --------------------------------------------------------------------------- # @register( "design.component_variants", summary="Component variants grid: every size/state/intent of a UI component.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "components": "List[{name, description?, axes: {axis_name: [labels...]}, " "cells: [{coords: {axis_name: label,...}, html}]}]. " "Cells are rendered as a grid; axes drive row/column headers.", }, ) def component_variants(spec: dict) -> dict: sections = [] for comp in spec.get("components", []): axes = comp.get("axes") or {} cells = comp.get("cells") or [] axis_names = list(axes.keys()) if len(axis_names) >= 2: rows_axis, cols_axis = axis_names[0], axis_names[1] row_labels, col_labels = axes[rows_axis], axes[cols_axis] lookup: dict[tuple, str] = {} for cell in cells: co = cell.get("coords") or {} lookup[(co.get(rows_axis), co.get(cols_axis))] = cell.get("html", "") head = "<tr><th></th>" + "".join(f"<th>{c.esc(cl)}</th>" for cl in col_labels) + "</tr>" body_rows = [] for rl in row_labels: tds = "".join( f'<td style="background:var(--paper);padding:14px;">{lookup.get((rl, cl), "")}</td>' for cl in col_labels ) body_rows.append(f'<tr><th style="text-align:left;">{c.esc(rl)}</th>{tds}</tr>') grid_html = f'<table>{head}{"".join(body_rows)}</table>' else: grid_html = c.grid([ c.card( cell.get("html", ""), title=", ".join(f"{k}: {v}" for k, v in (cell.get("coords") or {}).items()) or None, ) for cell in cells ], cols="auto") sections.append(c.section( comp.get("name"), body=(f'<p style="color:var(--g700);margin-bottom:18px;">{c.esc(comp.get("description"))}</p>' if comp.get("description") else "") + grid_html, )) return { "title": spec.get("title", "Component variants"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "COMPONENTS"), "body": "".join(sections), "page_class": "page page--wide", } -
diagram.py 7.3 KB
"""Diagram & illustration templates. - diagram.svg_figure_sheet: Page of standalone SVG figures with captions. - diagram.flowchart: Vertical or horizontal step flowchart with labels. """ from __future__ import annotations import sys import composer as c from . import register # --------------------------------------------------------------------------- # # svg_figure_sheet # # --------------------------------------------------------------------------- # @register( "diagram.svg_figure_sheet", summary="Multiple SVG figures laid out as a page of captioned diagrams.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "intro_html": "Optional intro HTML.", "figures": "List[{label, svg: <svg>...</svg>, caption?, span?: 1|2}]. " "span=2 makes a figure full width on a 2-column grid.", }, ) def svg_figure_sheet(spec: dict) -> dict: cells = [] for f in spec.get("figures", []): span = int(f.get("span", 1)) cell = ( f'<figure class="fig" style="grid-column: span {min(span,2)};">' f'<div class="fig-frame">{f.get("svg","")}</div>' f'<figcaption>' f'<span class="fig-label">{c.esc(f.get("label",""))}</span>' + (f' <span>{c.esc(f.get("caption",""))}</span>' if f.get("caption") else "") + '</figcaption></figure>' ) cells.append(cell) body = ((f'<section>{spec.get("intro_html")}</section>' if spec.get("intro_html") else "") + c.section(body=f'<div class="fig-grid">{"".join(cells)}</div>')) extra_css = """ .fig-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 28px; } @media (max-width: 720px) { .fig-grid { grid-template-columns: 1fr; } .fig { grid-column: span 1 !important; } } .fig { margin: 0; } .fig-frame { background: var(--paper); border: var(--border); border-radius: var(--radius); padding: 24px; display: flex; align-items: center; justify-content: center; } .fig-frame svg { max-width: 100%; height: auto; } figcaption { margin-top: 10px; font-size: 13px; color: var(--g700); } .fig-label { font-family: var(--mono); font-size: 11px; color: var(--clay-d); margin-right: 6px; text-transform: uppercase; letter-spacing: .06em; } """ return { "title": spec.get("title", "Figures"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "DIAGRAMS"), "body": body, "extra_css": extra_css, } # --------------------------------------------------------------------------- # # flowchart # # --------------------------------------------------------------------------- # @register( "diagram.flowchart", summary="Step-by-step flowchart (vertical or horizontal). Each step has details.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "intro_html": "Optional intro HTML.", "orientation": "'vertical' (default) or 'horizontal'.", "steps": "List[{id, label, kind?: 'start|process|decision|end', " "details_html?, branches?: [{label, to_id}]}].", }, ) def flowchart(spec: dict) -> dict: orientation = spec.get("orientation", "vertical") steps = spec.get("steps") or [] n = len(steps) # SVG layout: vertical = single column, horizontal = single row box_w, box_h, gap = 220, 70, 80 if orientation == "horizontal": width = n * (box_w + gap) height = box_h + 60 else: width = box_w + 60 height = n * (box_h + gap) fill = {"start": "#E8EFE0", "process": "var(--paper)", "decision": "#F4D9C8", "end": "#F4DAD5"} stroke = {"start": "var(--ok)", "process": "var(--g300)", "decision": "var(--clay)", "end": "var(--err)"} pos = {} nodes = [] for i, s in enumerate(steps): if orientation == "horizontal": x, y = i * (box_w + gap) + 30, 30 else: x, y = 30, i * (box_h + gap) + 30 pos[s.get("id", f"s{i}")] = (x + box_w / 2, y + box_h / 2) kind = s.get("kind", "process") if kind == "decision": cx, cy = x + box_w / 2, y + box_h / 2 nodes.append( f'<polygon points="{cx},{y} {x+box_w},{cy} {cx},{y+box_h} {x},{cy}" ' f'fill="{fill["decision"]}" stroke="{stroke["decision"]}" stroke-width="1.5"/>' f'<text x="{cx}" y="{cy+5}" text-anchor="middle" font-family="ui-sans-serif" font-size="13" font-weight="600" fill="#141413">{c.esc(s.get("label"))}</text>' ) else: nodes.append( f'<rect x="{x}" y="{y}" width="{box_w}" height="{box_h}" rx="8" ' f'fill="{fill.get(kind,"var(--paper)")}" stroke="{stroke.get(kind,"var(--g300)")}" stroke-width="1.5"/>' f'<text x="{x+box_w/2}" y="{y+box_h/2+5}" text-anchor="middle" font-family="ui-sans-serif" font-size="13" font-weight="600" fill="#141413">{c.esc(s.get("label"))}</text>' ) # Edges: sequential, plus branches edges = [] for i, s in enumerate(steps): cur = pos[s.get("id", f"s{i}")] if i < n - 1 and not s.get("branches"): nxt = pos[steps[i + 1].get("id", f"s{i+1}")] edges.append(f'<line x1="{cur[0]}" y1="{cur[1]}" x2="{nxt[0]}" y2="{nxt[1]}" stroke="#9A9890" stroke-width="1.4" marker-end="url(#a)"/>') for br in (s.get("branches") or []): to_id = br.get("to_id") tgt = pos.get(to_id) if not tgt: print(f"composing-html warning: flowchart branch references unknown step id {to_id!r}", file=sys.stderr) continue edges.append(f'<line x1="{cur[0]}" y1="{cur[1]}" x2="{tgt[0]}" y2="{tgt[1]}" stroke="#9A9890" stroke-width="1.4" marker-end="url(#a)"/>') mx, my = (cur[0] + tgt[0]) / 2, (cur[1] + tgt[1]) / 2 edges.append(f'<text x="{mx}" y="{my}" font-family="ui-monospace" font-size="11" fill="#3D3D3A" text-anchor="middle" paint-order="stroke" stroke="#FAF9F5" stroke-width="3">{c.esc(br.get("label",""))}</text>') svg = ( f'<svg viewBox="0 0 {width} {height}" width="100%" style="max-width:{width}px;">' '<defs><marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">' '<path d="M0,0 L10,5 L0,10 Z" fill="#9A9890"/></marker></defs>' + "".join(edges) + "".join(nodes) + '</svg>' ) # Optional details below details_html = "" if any(s.get("details_html") for s in steps): rows = "".join( f'<details><summary>{c.esc(s.get("label"))}</summary>{s.get("details_html","")}</details>' for s in steps if s.get("details_html") ) details_html = c.section("Step details", body=rows) body = ((f'<section>{spec.get("intro_html")}</section>' if spec.get("intro_html") else "") + c.section(body=c.card(svg)) + details_html) return { "title": spec.get("title", "Flowchart"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "FLOW"), "body": body, "page_class": "page page--wide", } -
editor.py 11.8 KB
"""Custom-editor templates — throwaway purpose-built tools. - editor.triage_board: Drag-and-drop kanban for items across columns. - editor.flag_editor: Boolean toggles with dependency warnings. - editor.prompt_tuner: Live variable substitution into a template prompt. """ from __future__ import annotations import json import composer as c from . import register # --------------------------------------------------------------------------- # # triage_board # # --------------------------------------------------------------------------- # @register( "editor.triage_board", summary="Drag-and-drop board: columns of cards. Reorder within and across columns.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "columns": "List[{id, label, color?: 'clay|olive|info|err', items: [{id, title, body_html?, tags?: [str]}]}].", }, ) def triage_board(spec: dict) -> dict: color_map = {"clay":"var(--clay)","olive":"var(--olive)","info":"var(--info)","err":"var(--err)"} cols = [] for col in spec.get("columns", []): accent = color_map.get(col.get("color","clay"), "var(--clay)") items = [] for it in col.get("items", []): tags = "".join(c.badge(t) for t in (it.get("tags") or [])) items.append( f'<div class="board-card" draggable="true" data-id="{c.esc(it.get("id"))}">' f'<div class="board-card-title">{c.esc(it.get("title"))}</div>' + (f'<div class="board-card-body">{it.get("body_html","")}</div>' if it.get("body_html") else "") + (f'<div class="row" style="margin-top:8px;">{tags}</div>' if tags else "") + '</div>' ) cols.append( f'<div class="board-col">' f'<div class="board-col-head" style="border-bottom:2px solid {accent};">' f'<span>{c.esc(col.get("label"))}</span>' f'<span class="board-col-count">{len(col.get("items") or [])}</span></div>' f'<div class="board-col-body" data-sortable="true" data-zone="{c.esc(col.get("id"))}">{"".join(items)}</div>' f'</div>' ) body = c.section(body=f'<div class="board">{"".join(cols)}</div>') extra_css = """ .board { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; } .board-col { background: var(--g100); border: var(--border-soft); border-radius: var(--radius); display: flex; flex-direction: column; } .board-col-head { padding: 12px 14px; font-family: var(--mono); font-size: 12px; color: var(--g700); text-transform: uppercase; letter-spacing: .06em; display: flex; justify-content: space-between; } .board-col-count { color: var(--g500); } .board-col-body { padding: 10px; display: flex; flex-direction: column; gap: 8px; min-height: 80px; } .board-card { background: var(--paper); border: var(--border-soft); border-radius: var(--radius-sm); padding: 10px 12px; cursor: grab; } .board-card:active { cursor: grabbing; } .board-card-title { font-weight: 600; font-size: 14px; } .board-card-body { font-size: 13px; color: var(--g700); margin-top: 4px; } """ return { "title": spec.get("title", "Triage board"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "EDITOR"), "body": body, "extra_css": extra_css, "page_class": "page page--wide", } # --------------------------------------------------------------------------- # # flag_editor # # --------------------------------------------------------------------------- # @register( "editor.flag_editor", summary="Toggle editor for boolean flags with dependency warnings.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "flags": "List[{id, label, description?, default: bool, " "requires?: [flag_id], conflicts_with?: [flag_id]}].", }, ) def flag_editor(spec: dict) -> dict: flags = spec.get("flags") or [] rows = [] for f in flags: fid = f.get("id", "") requires_json = c.esc(json.dumps(f.get("requires") or [])) conflicts_json = c.esc(json.dumps(f.get("conflicts_with") or [])) checkbox = c.void("input", type="checkbox", checked=bool(f.get("default"))) rows.append( f'<div class="flag" data-id="{c.esc(fid)}" ' f'data-requires="{requires_json}" ' f'data-conflicts="{conflicts_json}">' f'<div class="flag-meta">' f'<div style="font-family:var(--mono);font-size:13px;">{c.esc(fid)}</div>' f'<div style="font-weight:600;">{c.esc(f.get("label"))}</div>' + (f'<div style="font-size:13px;color:var(--g700);margin-top:2px;">{c.esc(f.get("description"))}</div>' if f.get("description") else "") + '</div>' f'<label class="switch">{checkbox}<span class="slider"></span></label>' f'<div class="flag-warn" hidden></div></div>' ) body = c.section(body=f'<div class="flag-list">{"".join(rows)}</div>') extra_css = """ .flag-list { display: flex; flex-direction: column; gap: 10px; } .flag { display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: center; background: var(--paper); border: var(--border); border-radius: var(--radius); padding: 14px 18px; position: relative; } .flag-meta { min-width: 0; } .flag-warn { grid-column: 1 / -1; padding: 10px 12px; border-left: 3px solid var(--warn); background: #F8ECCB; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; font-size: 13px; } .switch { position: relative; display: inline-block; width: 44px; height: 24px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; inset: 0; background: var(--g300); border-radius: 24px; transition: .15s; } .slider::before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; top: 3px; background: white; border-radius: 50%; transition: .15s; } .switch input:checked + .slider { background: var(--clay); } .switch input:checked + .slider::before { transform: translateX(20px); } """ extra_js = """ (function(){ function checked(id){ var el = document.querySelector('.flag[data-id="'+id+'"] input[type=checkbox]'); return !!(el && el.checked); } function setWarn(flagEl, msg){ var w = flagEl.querySelector('.flag-warn'); if (!w) return; if (msg) { w.textContent = msg; w.hidden = false; } else { w.hidden = true; w.textContent = ''; } } function recompute(){ document.querySelectorAll('.flag').forEach(function(flag){ if (!flag.querySelector('input[type=checkbox]').checked) { setWarn(flag, ''); return; } var requires = JSON.parse(flag.dataset.requires || '[]'); var conflicts = JSON.parse(flag.dataset.conflicts || '[]'); var missing = requires.filter(function(r){ return !checked(r); }); var clashes = conflicts.filter(function(r){ return checked(r); }); if (missing.length) setWarn(flag, 'Requires: ' + missing.join(', ')); else if (clashes.length) setWarn(flag, 'Conflicts with: ' + clashes.join(', ')); else setWarn(flag, ''); }); } document.querySelectorAll('.flag input[type=checkbox]').forEach(function(el){ el.addEventListener('change', recompute); }); recompute(); })(); """ return { "title": spec.get("title", "Flag editor"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "EDITOR"), "body": body, "extra_css": extra_css, "extra_js": extra_js, } # --------------------------------------------------------------------------- # # prompt_tuner # # --------------------------------------------------------------------------- # @register( "editor.prompt_tuner", summary="Prompt template tuner: edit variables, see live-rendered final prompt.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "template": "Prompt template string. Use {{variable_name}} placeholders.", "variables": "List[{name, label?, default, type?: 'text|textarea|select', options?: [str]}].", }, ) def prompt_tuner(spec: dict) -> dict: template = spec.get("template", "") variables = spec.get("variables") or [] inputs = [] for v in variables: label = c.esc(v.get("label") or v["name"]) if v.get("type") == "textarea": inputs.append(f'<label>{label}<textarea data-var="{c.esc(v["name"])}" rows="4">{c.esc(v.get("default",""))}</textarea></label>') elif v.get("type") == "select": opts = "".join(f'<option value="{c.esc(o)}" {"selected" if o==v.get("default") else ""}>{c.esc(o)}</option>' for o in (v.get("options") or [])) inputs.append(f'<label>{label}<select data-var="{c.esc(v["name"])}">{opts}</select></label>') else: inputs.append(f'<label>{label}<input type="text" data-var="{c.esc(v["name"])}" value="{c.esc(v.get("default",""))}"></label>') body = c.section(body=( f'<div class="tuner">' f'<div class="tuner-vars">{"".join(inputs)}</div>' f'<div class="tuner-out">' f'<div class="tuner-out-label">RENDERED PROMPT</div>' f'<pre id="rendered"></pre></div>' f'</div>' )) # Defend against </script> breakout in the template literal. template_json = json.dumps(template).replace("</", "<\\/") extra_css = """ .tuner { display: grid; grid-template-columns: 360px 1fr; gap: 24px; } @media (max-width: 880px) { .tuner { grid-template-columns: 1fr; } } .tuner-vars { display: flex; flex-direction: column; gap: 14px; background: var(--g100); padding: 18px; border-radius: var(--radius); } .tuner-vars label { display: flex; flex-direction: column; gap: 6px; font-family: var(--mono); font-size: 12px; color: var(--g700); } .tuner-vars input, .tuner-vars textarea, .tuner-vars select { font-family: var(--mono); font-size: 13px; padding: 8px 10px; border: 1px solid var(--g300); border-radius: 6px; background: var(--paper); color: var(--slate); } .tuner-out-label { font-family: var(--mono); font-size: 11px; color: var(--g500); letter-spacing: .06em; margin-bottom: 8px; } .tuner-out pre { white-space: pre-wrap; word-break: break-word; } """ # JS is deliberately not an f-string past the template literal — the only # interpolation is `template_json`, which is JSON-safe and </-escaped above. extra_js = ( "(function(){\n" " var tpl = " + template_json + ";\n" " function escapeRe(s){ return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'); }\n" " function render(){\n" " var out = tpl;\n" " document.querySelectorAll('[data-var]').forEach(function(el){\n" " var re = new RegExp('\\\\{\\\\{\\\\s*' + escapeRe(el.dataset.var) + '\\\\s*\\\\}\\\\}', 'g');\n" " var v = el.value;\n" " out = out.replace(re, function(){ return v; });\n" " });\n" " document.getElementById('rendered').textContent = out;\n" " }\n" " document.querySelectorAll('[data-var]').forEach(function(el){ el.addEventListener('input', render); });\n" " render();\n" "})();\n" ) return { "title": spec.get("title", "Prompt tuner"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "EDITOR"), "body": body, "extra_css": extra_css, "extra_js": extra_js, } -
exploration.py 8.8 KB
"""Exploration & planning templates. - exploration.comparison_grid : N options side-by-side with pros/cons/tags. - exploration.design_directions: Visual direction cards with sample swatches. - exploration.implementation_plan: Milestones, risks, and a flow sketch. """ from __future__ import annotations import composer as c from . import register # --------------------------------------------------------------------------- # # comparison_grid # # --------------------------------------------------------------------------- # @register( "exploration.comparison_grid", summary="N options side-by-side: verdict, summary, pros, cons, tags.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "options": "List[{name, verdict, summary, pros[], cons[], tags[]}].", "recommendation": "Optional name of the recommended option (highlighted).", }, ) def comparison_grid(spec: dict) -> dict: options = spec.get("options", []) rec = spec.get("recommendation") cards = [] for opt in options: is_rec = rec and opt.get("name") == rec head_badge = c.badge("recommended", "clay") if is_rec else "" body = ( (f'<div class="row" style="justify-content:space-between;align-items:flex-start;">' f'<h3>{c.esc(opt.get("name"))}</h3>{head_badge}</div>') + (f'<p style="font-style:italic;color:var(--clay-d);margin:4px 0 12px;">{c.esc(opt.get("verdict"))}</p>' if opt.get("verdict") else "") + (f'<p style="margin:0 0 16px;">{c.esc(opt.get("summary"))}</p>' if opt.get("summary") else "") + (f'<div style="font-family:var(--mono);font-size:11px;color:var(--ok);margin-bottom:4px;">PROS</div>{c.bullets(opt.get("pros") or [])}' if opt.get("pros") else "") + (f'<div style="font-family:var(--mono);font-size:11px;color:var(--err);margin:14px 0 4px;">CONS</div>{c.bullets(opt.get("cons") or [])}' if opt.get("cons") else "") + (f'<div class="row" style="margin-top:16px;">{"".join(c.badge(t) for t in (opt.get("tags") or []))}</div>' if opt.get("tags") else "") ) cards.append(c.card(body, elev=is_rec, extra_class="opt-card" + (" opt-rec" if is_rec else ""))) body_html = c.section(body=c.grid(cards, cols=min(3, max(1, len(cards))))) extra_css = """ .opt-card { display: flex; flex-direction: column; } .opt-rec { border-color: var(--clay); border-width: 2px; } """ return { "title": spec.get("title", "Comparison"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "EXPLORATION"), "body": body_html, "extra_css": extra_css, } # --------------------------------------------------------------------------- # # design_directions # # --------------------------------------------------------------------------- # @register( "exploration.design_directions", summary="Visual design directions: name, vibe, palette swatches, sample type.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "directions": "List[{name, vibe, palette: [hex...], typography: {display, body}, notes}].", }, ) def design_directions(spec: dict) -> dict: cards = [] for d in spec.get("directions", []): palette = d.get("palette") or [] swatches = "".join( f'<div style="background:{c.css_color(hx, default="#ccc")};height:48px;border-radius:6px;' f'border:1px solid rgba(0,0,0,.06);" title="{c.esc(hx)}"></div>' for hx in palette ) typo = d.get("typography") or {} sample = "" if typo: disp = c.esc(typo.get("display", "Aa")) body = c.esc(typo.get("body", "The quick brown fox jumps over the lazy dog.")) sample = (f'<div style="margin-top:14px;padding:14px;background:var(--g100);border-radius:8px;">' f'<div style="font-family:var(--serif);font-size:30px;line-height:1;margin-bottom:6px;">{disp}</div>' f'<div style="font-size:13px;color:var(--g700);">{body}</div></div>') body = ( f'<h3>{c.esc(d.get("name"))}</h3>' + (f'<p style="color:var(--clay-d);margin:2px 0 14px;">{c.esc(d.get("vibe"))}</p>' if d.get("vibe") else "") + (f'<div style="display:grid;grid-template-columns:repeat({len(palette) or 1},1fr);gap:6px;">{swatches}</div>' if palette else "") + sample + (f'<p style="margin-top:14px;font-size:14px;color:var(--g700);">{c.esc(d.get("notes"))}</p>' if d.get("notes") else "") ) cards.append(c.card(body)) body_html = c.section(body=c.grid(cards, cols=min(3, max(1, len(cards))))) return { "title": spec.get("title", "Design directions"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "EXPLORATION"), "body": body_html, } # --------------------------------------------------------------------------- # # implementation_plan # # --------------------------------------------------------------------------- # @register( "exploration.implementation_plan", summary="Implementation plan: milestones, owners, risks, and an optional flow sketch.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "summary_html": "Optional rich HTML intro (one or two paragraphs).", "milestones": "List[{name, when, owner?, deliverables: [str], status?: 'done|active|next|later'}].", "risks": "List[{risk, likelihood: 'low|med|high', impact: 'low|med|high', mitigation}].", "data_flow": "Optional list of strings; rendered as arrow-connected boxes (left-to-right).", }, ) def implementation_plan(spec: dict) -> dict: # Milestones as a vertical timeline status_color = {"done": "var(--ok)", "active": "var(--clay)", "next": "var(--info)", "later": "var(--g500)"} owner_style = "font-family:var(--mono);font-size:12px;color:var(--g500);margin-bottom:6px;" ms_rows = [] for m in spec.get("milestones", []): sc = status_color.get(m.get("status", "next"), "var(--g500)") deliverables = c.bullets(m.get("deliverables") or []) owner_html = f'<div style="{owner_style}">{c.esc(m.get("owner"))}</div>' if m.get("owner") else "" ms_rows.append( f'<div style="display:grid;grid-template-columns:140px 14px 1fr;gap:18px;padding:14px 0;border-bottom:1px solid var(--g150);">' f'<div><div style="font-family:var(--mono);font-size:12px;color:var(--g500);">{c.esc(m.get("when"))}</div>' f'{(c.badge(m.get("status","next").upper(), kind="info") if m.get("status") else "")}</div>' f'<div><div style="width:10px;height:10px;border-radius:50%;background:{sc};margin-top:6px;"></div></div>' f'<div><h3 style="margin-bottom:6px;">{c.esc(m.get("name"))}</h3>' f'{owner_html}' f'{deliverables}</div>' f'</div>' ) milestones_html = c.section("Milestones", body="".join(ms_rows)) if ms_rows else "" # Risks table risks = spec.get("risks") or [] risks_html = "" if risks: rows = [[r.get("risk"), c.raw(c.badge((r.get("likelihood") or "med").upper(), "warn")), c.raw(c.badge((r.get("impact") or "med").upper(), "err")), r.get("mitigation")] for r in risks] risks_html = c.section("Risks", body=c.table(["Risk", "Likelihood", "Impact", "Mitigation"], rows)) # Data flow boxes flow = spec.get("data_flow") or [] flow_html = "" if flow: boxes = [] for i, step in enumerate(flow): boxes.append(f'<div class="flow-box">{c.esc(step)}</div>') if i < len(flow) - 1: boxes.append('<div class="flow-arrow">→</div>') flow_html = c.section("Data flow", body=f'<div class="flow-row">{"".join(boxes)}</div>') summary_html = spec.get("summary_html") or "" body = ((f'<section>{summary_html}</section>' if summary_html else "") + flow_html + milestones_html + risks_html) extra_css = """ .flow-row { display:flex; align-items:center; gap:14px; flex-wrap:wrap; } .flow-box { background:var(--paper); border:var(--border); border-radius:var(--radius); padding:14px 18px; font-family:var(--mono); font-size:13px; min-width:120px; text-align:center; } .flow-arrow { color:var(--clay); font-size:24px; font-weight:700; } """ return { "title": spec.get("title", "Implementation plan"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "PLAN"), "body": body, "extra_css": extra_css, } -
freeform.py 1.6 KB
"""freeform — minimal shell, Claude provides arbitrary inner HTML. Use when no other template fits. Page chrome (head, css, js, masthead, colophon) is composed by the shell; only the body content is the caller's responsibility. """ from __future__ import annotations from . import register @register("freeform", summary="Base shell with caller-supplied inner HTML.", spec_keys={ "title": "Page title (string).", "subtitle": "Optional sub-headline shown under the title.", "eyebrow": "Optional small all-caps label above the title.", "body_html": "Raw HTML for the page body. May use any class from base.css.", "extra_css": "Optional extra CSS injected into <style> after base.css.", "extra_js": "Optional extra JS appended after base.js.", "page_class": "Optional layout: 'page', 'page page--wide', 'page page--narrow'.", "show_masthead": "Bool, default true. Set false to omit the title block entirely.", "body_attrs": "Optional dict of attrs on <body> (e.g. {'data-deck': 'true'}).", }) def build(spec: dict) -> dict: return { "title": spec.get("title", "Untitled"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow"), "body": spec.get("body_html", ""), "extra_css": spec.get("extra_css", ""), "extra_js": spec.get("extra_js", ""), "page_class": spec.get("page_class", "page"), "show_masthead": spec.get("show_masthead", True), "body_attrs": spec.get("body_attrs"), } -
prototype.py 6.1 KB
"""Prototype templates. - prototype.animation_sandbox: Interactive parameter knobs that drive a visual element via CSS variables. - prototype.click_flow: Sequence of mockup screens with arrow links. """ from __future__ import annotations import composer as c from . import register # --------------------------------------------------------------------------- # # animation_sandbox # # --------------------------------------------------------------------------- # @register( "prototype.animation_sandbox", summary="Live-tunable parameters (sliders/selects) driving a CSS-variable preview.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "intro_html": "Optional intro HTML.", "params": "List[{name, label, type: 'range|select', min?, max?, step?, default, unit?, options?: [str], format?: 'number|percent|ms'}]. " "Param values are exposed as CSS vars `--bind-<name>` and live-rendered via base.js.", "preview_html": "HTML for the preview region. Use the CSS vars `--bind-<name>` to react to params.", "preview_height": "Optional CSS height for preview region (default: '320px').", }, ) def animation_sandbox(spec: dict) -> dict: controls = [] for p in spec.get("params", []): name = p["name"] label = c.esc(p.get("label", name)) if p.get("type") == "select": opts = "".join(f'<option value="{c.esc(o)}">{c.esc(o)}</option>' for o in p.get("options", [])) ctl = (f'<select data-bind="{c.esc(name)}" data-format="{c.esc(p.get("format",""))}">{opts}</select>') else: ctl = (f'<input type="range" data-bind="{c.esc(name)}" ' f'min="{p.get("min", 0)}" max="{p.get("max", 100)}" step="{p.get("step", 1)}" ' f'value="{p.get("default", 0)}" ' f'data-format="{c.esc(p.get("format",""))}" ' f'data-unit="{c.esc(p.get("unit",""))}">') controls.append( f'<div class="ctrl">' f'<div class="ctrl-row"><label>{label}</label>' f'<span class="ctrl-val" data-out="{c.esc(name)}"></span></div>' f'{ctl}</div>' ) preview_h = c.esc(spec.get("preview_height", "320px")) body = ((f'<section>{spec.get("intro_html")}</section>' if spec.get("intro_html") else "") + c.section(body=( f'<div class="sandbox">' f'<div class="sandbox-preview" style="height:{preview_h};">{spec.get("preview_html","")}</div>' f'<div class="sandbox-controls">{"".join(controls)}</div>' f'</div>' ))) extra_css = """ .sandbox { display: grid; grid-template-columns: 1fr 280px; gap: 24px; } @media (max-width: 880px) { .sandbox { grid-template-columns: 1fr; } } .sandbox-preview { background: var(--paper); border: var(--border); border-radius: var(--radius); display: flex; align-items: center; justify-content: center; overflow: hidden; } .sandbox-controls { display: flex; flex-direction: column; gap: 18px; background: var(--g100); border: var(--border-soft); border-radius: var(--radius); padding: 20px; } .ctrl-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; } .ctrl-row label { font-family: var(--mono); font-size: 12px; color: var(--g700); } .ctrl-val { font-family: var(--mono); font-size: 12px; color: var(--clay-d); } .ctrl input[type="range"], .ctrl select { width: 100%; } """ return { "title": spec.get("title", "Prototype"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "PROTOTYPE"), "body": body, "extra_css": extra_css, } # --------------------------------------------------------------------------- # # click_flow # # --------------------------------------------------------------------------- # @register( "prototype.click_flow", summary="Sequence of mockup screens connected by labelled arrows.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "screens": "List[{id, label, html, transitions?: [{label, to_id}]}].", }, ) def click_flow(spec: dict) -> dict: screens = spec.get("screens") or [] cards = [] for s in screens: trs = "" if s.get("transitions"): chips = " ".join( f'<a href="#{c.esc(t["to_id"])}" class="tr">{c.esc(t.get("label","→"))}</a>' for t in s["transitions"] ) trs = f'<div class="screen-tr">{chips}</div>' cards.append( f'<div class="screen" id="{c.esc(s.get("id"))}">' f'<div class="screen-head">{c.esc(s.get("label"))}</div>' f'<div class="screen-body">{s.get("html","")}</div>' f'{trs}</div>' ) body = c.section(body=f'<div class="flow-grid">{"".join(cards)}</div>') extra_css = """ .flow-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 24px; } .screen { display: flex; flex-direction: column; background: var(--paper); border: var(--border); border-radius: var(--radius); overflow: hidden; } .screen-head { padding: 10px 14px; background: var(--g100); font-family: var(--mono); font-size: 12px; color: var(--g500); } .screen-body { padding: 16px; flex: 1; min-height: 200px; } .screen-tr { padding: 10px 14px; border-top: 1px solid var(--g150); display: flex; gap: 8px; flex-wrap: wrap; } .tr { background: var(--oat); color: var(--clay-d); font-family: var(--mono); font-size: 11px; padding: 4px 10px; border-radius: 999px; text-decoration: none; } .tr:hover { background: var(--clay); color: white; } """ return { "title": spec.get("title", "Click flow"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "PROTOTYPE"), "body": body, "extra_css": extra_css, "page_class": "page page--wide", } -
report.py 6.8 KB
"""Report templates. - report.status_report: Periodic status report — shipped/in-flight/blocked + metrics. - report.incident_report: Timeline-based incident postmortem. """ from __future__ import annotations import composer as c from . import register # --------------------------------------------------------------------------- # # status_report # # --------------------------------------------------------------------------- # @register( "report.status_report", summary="Status report: header metrics, shipped/in-flight/blocked sections, optional charts (SVG).", spec_keys={ "title": "Page title (e.g. 'Platform Eng — Week of Mar 10').", "subtitle": "Optional sub-headline.", "metrics": "List[{label, value, delta?, kind?: 'ok|warn|err|info'}]. Rendered as KPI strip.", "shipped": "List[{title, owner?, body_html?}].", "in_flight": "List[{title, owner?, body_html?, progress?: 0..1}].", "blocked": "List[{title, owner?, body_html?}].", "extra_html": "Optional extra HTML appended at the end (e.g. a chart).", }, ) def status_report(spec: dict) -> dict: parts = [] metrics = spec.get("metrics") or [] if metrics: cells = [] for m in metrics: kind = m.get("kind", "info") color = {"ok":"var(--ok)","warn":"var(--warn)","err":"var(--err)","info":"var(--info)"}.get(kind,"var(--info)") delta = "" if m.get("delta"): delta = f'<div style="font-family:var(--mono);font-size:12px;color:{color};margin-top:4px;">{c.esc(m["delta"])}</div>' cells.append( f'<div style="border-left:3px solid {color};padding:14px 18px;background:var(--paper);border-radius:0 var(--radius-sm) var(--radius-sm) 0;">' f'<div style="font-family:var(--mono);font-size:11px;color:var(--g500);text-transform:uppercase;letter-spacing:.06em;">{c.esc(m.get("label"))}</div>' f'<div style="font-family:var(--serif);font-size:32px;line-height:1;margin-top:6px;">{c.esc(m.get("value"))}</div>' f'{delta}</div>' ) parts.append(c.section(body=c.grid(cells, cols=min(4, max(1, len(cells)))))) def _items_section(label: str, items: list, status_kind: str | None = None) -> str: if not items: return "" rows = [] for it in items: head = (f'<div class="row" style="justify-content:space-between;">' f'<h3>{c.esc(it.get("title"))}</h3>' + (c.badge(it["owner"]) if it.get("owner") else "") + '</div>') prog = "" if it.get("progress") is not None: pct = max(0, min(100, int(float(it["progress"]) * 100))) prog = (f'<div style="margin-top:10px;height:6px;background:var(--g200);border-radius:3px;overflow:hidden;">' f'<div style="width:{pct}%;height:100%;background:var(--clay);"></div></div>' f'<div style="font-family:var(--mono);font-size:11px;color:var(--g500);margin-top:4px;">{pct}%</div>') body = head + (it.get("body_html") or "") + prog rows.append(c.card(body)) return c.section(label, body=c.stack(rows)) parts.append(_items_section("Shipped", spec.get("shipped") or [])) parts.append(_items_section("In flight", spec.get("in_flight") or [])) parts.append(_items_section("Blocked", spec.get("blocked") or [])) if spec.get("extra_html"): parts.append(c.section(body=spec["extra_html"])) return { "title": spec.get("title", "Status report"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "STATUS"), "body": "".join(parts), } # --------------------------------------------------------------------------- # # incident_report # # --------------------------------------------------------------------------- # @register( "report.incident_report", summary="Incident postmortem: header severity, summary, minute-by-minute timeline, follow-ups.", spec_keys={ "title": "Page title (e.g. 'INC-1247 — Search outage').", "subtitle": "Optional sub-headline.", "severity": "'sev1|sev2|sev3|sev4'.", "duration": "Human duration string (e.g. '47m').", "impact_html": "HTML describing user impact.", "summary_html": "Optional executive summary HTML.", "timeline": "List[{at, event, kind?: 'detected|triage|mitigation|resolved|note'}].", "root_cause_html": "Optional root cause HTML.", "followups": "List[{title, owner?, due?, status?: 'open|done'}].", }, ) def incident_report(spec: dict) -> dict: sev = (spec.get("severity") or "sev3").upper() sev_kind = {"SEV1":"err","SEV2":"err","SEV3":"warn","SEV4":"info"}.get(sev, "warn") head = c.card( c.row([c.badge(sev, sev_kind), (c.badge(f'Duration {spec["duration"]}', "info") if spec.get("duration") else "")]) + (f'<div style="margin-top:14px;">{spec["impact_html"]}</div>' if spec.get("impact_html") else "") ) summary = c.section("Summary", body=spec["summary_html"]) if spec.get("summary_html") else "" tl_kind_color = {"detected":"var(--err)","triage":"var(--warn)","mitigation":"var(--info)", "resolved":"var(--ok)","note":"var(--g500)"} tl_rows = [] for ev in spec.get("timeline", []): col = tl_kind_color.get(ev.get("kind","note"), "var(--g500)") tl_rows.append( f'<div style="display:grid;grid-template-columns:90px 14px 1fr;gap:18px;padding:10px 0;border-bottom:1px solid var(--g150);">' f'<div style="font-family:var(--mono);font-size:12px;color:var(--g500);">{c.esc(ev.get("at"))}</div>' f'<div><div style="width:10px;height:10px;border-radius:50%;background:{col};margin-top:6px;"></div></div>' f'<div>{c.esc(ev.get("event"))}</div></div>' ) timeline = c.section("Timeline", body="".join(tl_rows)) if tl_rows else "" rc = c.section("Root cause", body=spec["root_cause_html"]) if spec.get("root_cause_html") else "" followups = "" if spec.get("followups"): rows = [[fu.get("title"), fu.get("owner") or "", fu.get("due") or "", c.raw(c.badge((fu.get("status","open")).upper(), "ok" if fu.get("status") == "done" else "warn"))] for fu in spec["followups"]] followups = c.section("Follow-ups", body=c.table(["Action", "Owner", "Due", "Status"], rows)) body = head + summary + timeline + rc + followups return { "title": spec.get("title", "Incident"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "INCIDENT"), "body": body, } -
research.py 4.6 KB
"""Research & learning templates. - research.feature_explainer: Tabbed walkthrough with code samples and a TOC. - research.concept_explainer: Inline concept doc with collapsibles + glossary. """ from __future__ import annotations import composer as c from . import register # --------------------------------------------------------------------------- # # feature_explainer # # --------------------------------------------------------------------------- # @register( "research.feature_explainer", summary="Feature explainer: TOC + sections + tabbed code samples per section.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "intro_html": "Optional intro HTML.", "sections": "List[{id, heading, body_html, code_tabs?: [{label, code, lang?}]}].", }, ) def feature_explainer(spec: dict) -> dict: secs = spec.get("sections") or [] toc_links = "".join(f'<a href="#{c.esc(s.get("id"))}">{c.esc(s.get("heading"))}</a>' for s in secs) toc_html = (f'<aside class="toc"><div class="toc-label">CONTENTS</div>{toc_links}</aside>' if toc_links else "") parts = [] for s in secs: tabs_html = "" if s.get("code_tabs"): panels = [{"id": f'{s.get("id","s")}-{i}', "label": t.get("label", f"Sample {i+1}"), "html": c.code_block(t.get("code",""), lang=t.get("lang"))} for i, t in enumerate(s["code_tabs"])] tabs_html = c.tabs(panels) parts.append( f'<section id="{c.esc(s.get("id"))}"><h2>{c.esc(s.get("heading"))}</h2><hr class="rule">' f'<div>{s.get("body_html","")}</div>' + (f'<div style="margin-top:18px;">{tabs_html}</div>' if tabs_html else "") + '</section>' ) body = (f'<div class="explainer">' f'{toc_html}' f'<div class="explainer-body">' + (f'<section>{spec.get("intro_html")}</section>' if spec.get("intro_html") else "") + "".join(parts) + '</div></div>') extra_css = """ .explainer { display: grid; grid-template-columns: 200px 1fr; gap: 48px; align-items: start; } @media (max-width: 880px) { .explainer { grid-template-columns: 1fr; } .toc { position: static !important; } } .toc { position: sticky; top: 24px; display: flex; flex-direction: column; gap: 4px; } .toc-label { font-family: var(--mono); font-size: 11px; letter-spacing: .08em; color: var(--g500); margin-bottom: 8px; } .toc a { font-size: 14px; color: var(--g700); padding: 4px 0; text-decoration: none; border-left: 2px solid var(--g200); padding-left: 12px; } .toc a:hover { color: var(--clay-d); border-left-color: var(--clay); } """ return { "title": spec.get("title", "Feature"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "EXPLAINER"), "body": body, "extra_css": extra_css, "page_class": "page page--wide", } # --------------------------------------------------------------------------- # # concept_explainer # # --------------------------------------------------------------------------- # @register( "research.concept_explainer", summary="Concept explainer: prose + interactive demo slot + glossary.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "body_html": "Main concept body HTML (use <h2>, <p>, <details>, etc).", "demo_html": "Optional HTML for an embedded demo region.", "glossary": "Optional list[{term, definition_html}].", }, ) def concept_explainer(spec: dict) -> dict: demo = "" if spec.get("demo_html"): demo = c.section("Demo", body=c.card(spec["demo_html"], elev=True)) glossary = "" if spec.get("glossary"): rows = "".join( f'<div style="display:grid;grid-template-columns:160px 1fr;gap:16px;padding:12px 0;border-bottom:1px solid var(--g150);">' f'<div style="font-family:var(--mono);font-size:13px;color:var(--clay-d);">{c.esc(g.get("term"))}</div>' f'<div>{g.get("definition_html","")}</div></div>' for g in spec["glossary"] ) glossary = c.section("Glossary", body=f'<div>{rows}</div>') body = (f'<section>{spec.get("body_html","")}</section>{demo}{glossary}') return { "title": spec.get("title", "Concept"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "CONCEPT"), "body": body, "page_class": "page page--narrow", } -
review.py 9.6 KB
"""Code review & understanding templates. - review.pr_review: PR header + per-file findings with severity tags. - review.code_walkthrough: Annotated explanation of a module or function. - review.module_map: Box-and-arrow diagram of package structure. """ from __future__ import annotations import sys import composer as c from . import register # --------------------------------------------------------------------------- # # pr_review # # --------------------------------------------------------------------------- # @register( "review.pr_review", summary="PR review writeup: header, summary, per-file findings with severity.", spec_keys={ "title": "Page title (e.g. 'PR #247 — Review').", "subtitle": "Optional sub-headline.", "pr": "Dict {repo, number, branch, author, additions, deletions}.", "verdict": "Optional one-liner verdict ('approve', 'request changes', etc).", "summary_html": "Optional rich HTML overview (1–2 paragraphs).", "findings": "List[{file, line?, severity: 'info|nit|warn|block', title, body_html, snippet?, snippet_lang?}].", }, ) def pr_review(spec: dict) -> dict: pr = spec.get("pr") or {} pr_meta = "".join([ c.badge(f"+{pr['additions']}", "ok") if pr.get("additions") is not None else "", c.badge(f"−{pr['deletions']}", "err") if pr.get("deletions") is not None else "", c.badge(pr.get("branch"), "info") if pr.get("branch") else "", c.badge(pr.get("author")) if pr.get("author") else "", ]) pr_head = ( f'<div style="font-family:var(--mono);font-size:12px;color:var(--g500);margin-bottom:8px;">' f'{c.esc(pr.get("repo"))}{(" · #"+str(pr.get("number"))) if pr.get("number") else ""}</div>' + (f'<div class="row" style="margin-top:10px;">{pr_meta}</div>' if pr_meta else "") ) verdict = spec.get("verdict") verdict_html = c.callout(verdict, kind="info") if verdict else "" summary_html = spec.get("summary_html") or "" sev = { "info": ("info", "i"), "nit": ("info", "·"), "warn": ("warn", "!"), "block": ("err", "✕"), } finding_cards = [] for f in spec.get("findings", []): kind, mark = sev.get(f.get("severity", "info"), ("info", "i")) snippet = "" if f.get("snippet"): snippet = c.code_block(f["snippet"], lang=f.get("snippet_lang")) loc = f.get("file", "") if f.get("line") is not None: loc = f"{loc}:{f['line']}" body = ( f'<div class="row" style="justify-content:space-between;align-items:flex-start;">' f'<div><div style="font-family:var(--mono);font-size:12px;color:var(--g500);">{c.esc(loc)}</div>' f'<h3 style="margin-top:4px;">{c.esc(f.get("title"))}</h3></div>' f'{c.badge(f.get("severity","info").upper(), kind)}</div>' + f'<div style="margin-top:10px;">{f.get("body_html","")}</div>' + (f'<div style="margin-top:10px;">{snippet}</div>' if snippet else "") ) finding_cards.append(c.card(body)) body = ( c.section(body=c.card(pr_head + (f'<div style="margin-top:14px;">{verdict_html}</div>' if verdict_html else ""))) + (c.section("Summary", body=summary_html) if summary_html else "") + c.section("Findings", body=c.stack(finding_cards) if finding_cards else "<p>No findings.</p>") ) return { "title": spec.get("title", "Review"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "CODE REVIEW"), "body": body, } # --------------------------------------------------------------------------- # # code_walkthrough # # --------------------------------------------------------------------------- # @register( "review.code_walkthrough", summary="Annotated walkthrough of code: numbered steps with snippets and prose.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "intro_html": "Optional introduction HTML.", "steps": "List[{heading, body_html, code?, lang?}].", }, ) def code_walkthrough(spec: dict) -> dict: rows = [] for i, s in enumerate(spec.get("steps", []), start=1): snippet = c.code_block(s["code"], lang=s.get("lang")) if s.get("code") else "" rows.append( f'<div style="display:grid;grid-template-columns:48px 1fr;gap:18px;margin-bottom:32px;">' f'<div style="font-family:var(--serif);font-size:32px;color:var(--clay);line-height:1;">{i:02d}</div>' f'<div><h3>{c.esc(s.get("heading"))}</h3>' f'<div style="margin:8px 0 12px;">{s.get("body_html","")}</div>' f'{snippet}</div></div>' ) body = ((f'<section>{spec.get("intro_html")}</section>' if spec.get("intro_html") else "") + c.section(body="".join(rows))) return { "title": spec.get("title", "Walkthrough"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "WALKTHROUGH"), "body": body, "page_class": "page page--narrow", } # --------------------------------------------------------------------------- # # module_map # # --------------------------------------------------------------------------- # @register( "review.module_map", summary="Module map: nodes (modules) and edges (dependencies) as SVG boxes/arrows.", spec_keys={ "title": "Page title.", "subtitle": "Optional sub-headline.", "intro_html": "Optional intro HTML.", "nodes": "List[{id, label, description?, x?: int, y?: int, kind?: 'core|util|external'}]. " "If x/y are missing, nodes are auto-laid in a grid.", "edges": "List[{from: id, to: id, label?}].", "legend": "Optional list[{label, kind}] to render a legend.", }, ) def module_map(spec: dict) -> dict: nodes = spec.get("nodes") or [] edges = spec.get("edges") or [] nw, nh = 180, 70 cols, cw, ch = 3, 220, 110 # Compute coordinates locally — never mutate the caller's spec. # Each node id falls back to its index if missing; nameless nodes are # still drawable and addressable from edges by `n0`, `n1`, ... pos: dict[str, tuple[float, float]] = {} for i, n in enumerate(nodes): nid = n.get("id") or f"n{i}" x = n["x"] if n.get("x") is not None else (i % cols) * cw + 30 y = n["y"] if n.get("y") is not None else (i // cols) * ch + 30 pos[nid] = (x, y) width = max((x + nw + 30 for (x, _y) in pos.values()), default=600) height = max((y + nh + 30 for (_x, y) in pos.values()), default=400) color = {"core": "#FCEEDE", "util": "#EAE8DF", "external": "#E0E7DA"} border = {"core": "var(--clay)", "util": "var(--g300)", "external": "var(--olive)"} edge_svg = [] for e in edges: src_id, dst_id = e.get("from"), e.get("to") if src_id not in pos or dst_id not in pos: print(f"composing-html warning: module_map edge references unknown id " f"({src_id!r} -> {dst_id!r})", file=sys.stderr) continue ax, ay = pos[src_id]; bx, by = pos[dst_id] x1, y1 = ax + nw / 2, ay + nh / 2 x2, y2 = bx + nw / 2, by + nh / 2 edge_svg.append( f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#9A9890" stroke-width="1.4" marker-end="url(#arrow)"/>' ) if e.get("label"): mx, my = (x1 + x2) / 2, (y1 + y2) / 2 edge_svg.append( f'<text x="{mx}" y="{my}" font-family="ui-monospace" font-size="10" fill="#87867F" ' f'text-anchor="middle" paint-order="stroke" stroke="#FAF9F5" stroke-width="3">{c.esc(e["label"])}</text>' ) node_svg = [] for i, n in enumerate(nodes): nid = n.get("id") or f"n{i}" x, y = pos[nid] kind = n.get("kind", "util") node_svg.append( f'<g transform="translate({x},{y})">' f'<rect width="{nw}" height="{nh}" rx="8" fill="{color.get(kind, "#EAE8DF")}" ' f'stroke="{border.get(kind, "var(--g300)")}" stroke-width="1.5"/>' f'<text x="14" y="26" font-family="ui-sans-serif" font-size="14" font-weight="600" fill="#141413">{c.esc(n.get("label"))}</text>' + (f'<text x="14" y="46" font-family="ui-monospace" font-size="11" fill="#3D3D3A">{c.esc(n.get("description"))}</text>' if n.get("description") else "") + '</g>' ) svg = ( f'<svg viewBox="0 0 {width} {height}" width="100%" style="max-width:{width}px;">' '<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">' '<path d="M0,0 L10,5 L0,10 Z" fill="#9A9890"/></marker></defs>' + "".join(edge_svg) + "".join(node_svg) + '</svg>' ) legend_html = "" if spec.get("legend"): legend_html = '<div class="row" style="margin-top:18px;">' + "".join( f'<span class="badge" style="background:{color.get(item.get("kind","util"))};">{c.esc(item.get("label"))}</span>' for item in spec["legend"]) + '</div>' body = ((f'<section>{spec.get("intro_html")}</section>' if spec.get("intro_html") else "") + c.section(body=c.card(svg + legend_html, soft=False))) return { "title": spec.get("title", "Module map"), "subtitle": spec.get("subtitle"), "eyebrow_text": spec.get("eyebrow", "ARCHITECTURE"), "body": body, "page_class": "page page--wide", } -
__init__.py 1.1 KB
"""Template registry. Each module in this package registers one or more named templates via the @register decorator. build.py looks them up here. """ from __future__ import annotations from collections.abc import Callable REGISTRY: dict[str, dict] = {} def register(name: str, *, summary: str, spec_keys: dict[str, str]) -> Callable: """Decorate a builder function ``f(spec: dict) -> str`` (returns the body HTML; composer.page() wraps it). summary: one-line description shown by ``build.py list``. spec_keys: ``{key: description}`` shown by ``build.py describe``. """ def deco(fn): REGISTRY[name] = {"build": fn, "summary": summary, "spec_keys": spec_keys} return fn return deco # Importing the modules below triggers the @register side-effects. from . import ( deck, # noqa: F401 design, # noqa: F401 diagram, # noqa: F401 editor, # noqa: F401 exploration, # noqa: F401 freeform, # noqa: F401 prototype, # noqa: F401 report, # noqa: F401 research, # noqa: F401 review, # noqa: F401 )
-
-
build.py 9.3 KB
#!/usr/bin/env python3 """composing-html / build.py — CLI entry. Usage ----- build.py list # all templates, one line each build.py describe <template> # parameter reference for one template build.py build <template> [--spec FILE|-] [--set K=V ...] [--out FILE] # render HTML; spec from FILE, stdin, # and/or --set overrides build.py check <file.html> [--json] # lint body/artifact for system drift # and miswired interactive hooks The "build" command reads a JSON spec, runs it through the named template's builder, and emits a single self-contained HTML document. With no --out, the result is written to stdout. --set lets you supply or override a spec field from the command line, including loading the value from a file via the @PATH suffix: build.py build freeform --set title='My Page' --set body_html=@body.html --out out.html This sidesteps JSON-string escaping for multi-line HTML/CSS/JS bodies. Templates live in scripts/templates/. Each module registers via the @register decorator. See SKILL.md for the workflow. """ from __future__ import annotations import argparse import json import sys from pathlib import Path ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(ROOT)) from composer import page from templates import REGISTRY def cmd_list(_args) -> int: width = max(len(name) for name in REGISTRY) if REGISTRY else 0 print(f"{len(REGISTRY)} templates available:\n") for name in sorted(REGISTRY): print(f" {name.ljust(width)} {REGISTRY[name]['summary']}") print("\nNext: build.py describe <template>") return 0 def _infer_default(desc: str): """Pick a JSON-valid placeholder value from a spec_keys description string. Heuristic — looks for shape hints like 'List', 'Dict', 'Bool', 'List[{...}]'. The output is always parseable JSON so the printed skeleton can be edited in place rather than retyped. """ d = desc.lower() if d.startswith("list") or d.startswith("optional list") or "list[" in d: import re as _re m = _re.search(r"\{([^}]+)\}", desc) if m: keys = [] for raw_key in m.group(1).split(","): # Each key looks like "name", "pros[]", "status?", "kind?: 'a|b'". key = raw_key.strip().rstrip("?").split(":")[0].strip() is_list_key = key.endswith("[]") key = key.rstrip("[]").strip() if not key or not key.isidentifier(): continue keys.append((key, is_list_key)) return [{k: ([] if is_l else "") for k, is_l in keys}] if keys else [] return [] if d.startswith("dict") or "dict[" in d: return {} if "bool" in d.split()[:3]: return False if d.startswith("optional"): return None return "" def cmd_describe(args) -> int: name = args.template if name not in REGISTRY: print(f"unknown template: {name}", file=sys.stderr) print(f"see: {sys.argv[0]} list", file=sys.stderr) return 2 entry = REGISTRY[name] print(f"# {name}\n\n{entry['summary']}\n") print("## Spec keys\n") for k, desc in entry["spec_keys"].items(): required = "" if desc.lower().startswith("optional") else " (required)" print(f"- `{k}`{required}: {desc}") print("\n## Starter spec (valid JSON — edit and pass to `build`)\n") skeleton = {k: _infer_default(desc) for k, desc in entry["spec_keys"].items()} print("```json") print(json.dumps(skeleton, indent=2, ensure_ascii=False)) print("```\n") print(f"For richer worked examples, see references/templates.md → ## {name}\n") print("Build with:\n") print(f" {sys.argv[0]} build {name} --spec spec.json --out out.html") return 0 def _apply_set(spec: dict, set_args: list[str]) -> int: """Mutate spec from --set KEY=VALUE / KEY=@FILE entries. Escape hatch for fields whose content fights JSON-string escaping (multi-line HTML, CSS, JS). Lets the caller keep the spec lean and point at raw files for the heavy bits. Syntax: --set body_html=@body.html # spec['body_html'] = file contents --set title='My Page' # spec['title'] = 'My Page' Returns 0 on success, 2 on error. """ for entry in set_args or []: if "=" not in entry: print(f"--set expects KEY=VALUE or KEY=@FILE, got: {entry!r}", file=sys.stderr) return 2 key, _, val = entry.partition("=") key = key.strip() if not key: print(f"--set missing key in: {entry!r}", file=sys.stderr) return 2 if val.startswith("@"): path = val[1:] if not path: print(f"--set {key}=@ requires a file path", file=sys.stderr) return 2 try: spec[key] = Path(path).read_text(encoding="utf-8") except OSError as e: print(f"--set {key}=@{path}: {e}", file=sys.stderr) return 2 else: spec[key] = val return 0 def cmd_build(args) -> int: name = args.template if name not in REGISTRY: print(f"unknown template: {name}", file=sys.stderr) return 2 # Spec is optional when every required field can be supplied via --set. if args.spec == "-": raw_text = sys.stdin.read() elif args.spec: raw_text = Path(args.spec).read_text(encoding="utf-8") else: raw_text = "" if raw_text.strip(): try: spec = json.loads(raw_text) except json.JSONDecodeError as e: print(f"spec is not valid JSON: {e}", file=sys.stderr) return 2 if not isinstance(spec, dict): print("spec must be a JSON object (dict)", file=sys.stderr) return 2 else: spec = {} rc = _apply_set(spec, args.set or []) if rc != 0: return rc if not spec: print("empty spec (no --spec content and no --set entries)", file=sys.stderr) return 2 builder = REGISTRY[name]["build"] page_kwargs = builder(spec) if not isinstance(page_kwargs, dict): print(f"template {name} did not return a dict", file=sys.stderr) return 2 html = page(**page_kwargs) if args.out: Path(args.out).write_text(html, encoding="utf-8") print(f"wrote {args.out} ({len(html):,} bytes)", file=sys.stderr) else: sys.stdout.write(html) return 0 def cmd_check(args) -> int: from checker import check_html, format_findings, has_errors path = Path(args.file) if not path.exists(): print(f"no such file: {args.file}", file=sys.stderr) return 2 html = path.read_text(encoding="utf-8") fragment = None if args.fragment: fragment = True elif args.full: fragment = False findings = check_html(html, fragment=fragment) if args.json: print(json.dumps( [{"rule": f.rule, "severity": f.severity, "message": f.message, "detail": f.detail} for f in findings], indent=2)) else: print(format_findings(findings)) return 1 if has_errors(findings) else 0 def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(prog="build.py", description="Compose HTML artifacts from a small JSON spec.") sub = p.add_subparsers(dest="cmd", required=True) sub.add_parser("list", help="List all available templates.").set_defaults(fn=cmd_list) pd = sub.add_parser("describe", help="Show the spec for one template.") pd.add_argument("template") pd.set_defaults(fn=cmd_describe) pb = sub.add_parser("build", help="Render a template using the given spec.") pb.add_argument("template") pb.add_argument("--spec", "-s", default=None, help="Path to JSON spec, or '-' for stdin. Optional if every " "required field is supplied via --set.") pb.add_argument("--set", action="append", default=[], metavar="KEY=VALUE", help="Set or override a spec field. 'KEY=VALUE' assigns the " "literal string; 'KEY=@FILE' loads the file contents. " "Repeatable. Avoids JSON-string escaping for multi-line " "HTML/CSS/JS (e.g. --set body_html=@body.html).") pb.add_argument("--out", "-o", default=None, help="Write HTML to this file (default: stdout).") pb.set_defaults(fn=cmd_build) pc = sub.add_parser("check", help="Lint a built artifact or body fragment for " "design-system drift and miswired hooks.") pc.add_argument("file", help="Path to the .html file to check.") pc.add_argument("--json", action="store_true", help="Emit findings as JSON.") pc.add_argument("--fragment", action="store_true", help="Force fragment mode (body_html, no page chrome).") pc.add_argument("--full", action="store_true", help="Force full-artifact mode (skip chrome-leak rule).") pc.set_defaults(fn=cmd_check) args = p.parse_args(argv) return args.fn(args) if __name__ == "__main__": raise SystemExit(main()) -
checker.py 12.9 KB
"""Deterministic structural linter for composing-html output. No LLM, no network, stdlib only. Catches the ways body content breaks *out* of the composer's fixed design system or miswires its interactive hooks — the inverse of a freehand design linter. The composer supplies a vetted token palette and type stacks; this checker flags content that bypasses them or wires `base.js` behaviours (tabs / binds / sortables) to nothing. Each rule carries a stable `id` that is anchored in SKILL.md (`<!-- rule:ID -->`) so the teaching and the enforcement stay linked. Public API: check_html(html, *, fragment=None) -> list[Finding] format_findings(findings) -> str Exit-code contract (CLI): 1 if any error-severity finding, else 0. Deliberately NOT checked in v1: colour-contrast ratios. The token pairs are pre-vetted, and contrast on author-introduced custom pairs needs colour math + rendering that a regex pass can't do without false positives. Deferred. """ from __future__ import annotations import html.parser as _hp import re from dataclasses import dataclass, field # --------------------------------------------------------------------------- # # canonical design tokens (mirror of assets/base.css :root) # # --------------------------------------------------------------------------- # BASE_TOKENS = { "ivory", "paper", "slate", "clay", "clay-d", "oat", "olive", "rust", "moss", "g100", "g150", "g200", "g300", "g500", "g700", "border", "border-soft", "radius", "radius-sm", "radius-lg", "shadow-card", "shadow-pop", "serif", "sans", "mono", "ok", "warn", "err", "info", } # colours that are fine as literals (not palette drift) _COLOR_KEYWORD_OK = {"transparent", "currentcolor", "inherit", "none", "initial", "unset"} _HEX_RE = re.compile(r"#[0-9a-fA-F]{3,8}\b") _FUNC_COLOR_RE = re.compile(r"\b(?:rgb|rgba|hsl|hsla)\s*\(", re.IGNORECASE) _VAR_RE = re.compile(r"var\(\s*--([a-z0-9-]+)", re.IGNORECASE) _FONT_FAMILY_RE = re.compile(r"font-family\s*:", re.IGNORECASE) _FONT_SIZE_RE = re.compile(r"font-size\s*:", re.IGNORECASE) # --------------------------------------------------------------------------- # # findings # # --------------------------------------------------------------------------- # @dataclass class Finding: rule: str severity: str # "error" | "warn" message: str detail: str = "" def __str__(self) -> str: tail = f" — {self.detail}" if self.detail else "" return f"[{self.severity}] {self.rule}: {self.message}{tail}" # --------------------------------------------------------------------------- # # minimal DOM (parent pointers; enough for structural rules) # # --------------------------------------------------------------------------- # _VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"} @dataclass class Node: tag: str attrs: dict parent: Node | None = None children: list = field(default_factory=list) @property def classes(self) -> set: return set((self.attrs.get("class") or "").split()) class _DOM(_hp.HTMLParser): def __init__(self): super().__init__(convert_charrefs=True) self.root = Node("#root", {}) self._stack = [self.root] self.nodes: list[Node] = [] def handle_starttag(self, tag, attrs): node = Node(tag, {k: (v or "") for k, v in attrs}, parent=self._stack[-1]) self._stack[-1].children.append(node) self.nodes.append(node) if tag not in _VOID: self._stack.append(node) def handle_startendtag(self, tag, attrs): node = Node(tag, {k: (v or "") for k, v in attrs}, parent=self._stack[-1]) self._stack[-1].children.append(node) self.nodes.append(node) def handle_endtag(self, tag): for i in range(len(self._stack) - 1, 0, -1): if self._stack[i].tag == tag: del self._stack[i:] break def _parse(html: str) -> _DOM: dom = _DOM() dom.feed(html) return dom def _ancestors(node: Node): p = node.parent while p is not None: yield p p = p.parent # --------------------------------------------------------------------------- # # rules # # --------------------------------------------------------------------------- # def _is_fragment(html: str) -> bool: head = html.lstrip()[:200].lower() return not (head.startswith("<!doctype") or head.startswith("<html") or "<html" in head[:200]) def _rule_chrome_leak(html: str, dom: _DOM, fragment: bool, out: list): # SKILL.md output rule 1: body_html must never contain page chrome. # Only meaningful for fragments — a full artifact is *expected* to have it. if not fragment: return for node in dom.nodes: if node.tag in ("html", "head", "link"): out.append(Finding("chrome-leak", "error", f"<{node.tag}> in body content", "the composer supplies page chrome; body_html must not")) elif node.tag in ("style", "script"): out.append(Finding("chrome-leak", "warn", f"top-level <{node.tag}> in body content", "prefer extra_css / extra_js spec fields")) def _defined_tokens(html: str, dom: _DOM) -> set: """Tokens legitimately available: base palette + --bind-* (created by data-bind hooks) + any custom --x declared in an author <style>.""" defined = set(BASE_TOKENS) for m in re.finditer(r"data-bind\s*=\s*[\"']([a-z0-9_-]+)", html, re.IGNORECASE): defined.add("bind-" + m.group(1)) # author-declared custom properties: --foo: for m in re.finditer(r"(?<![a-z0-9-])--([a-z0-9-]+)\s*:", html, re.IGNORECASE): defined.add(m.group(1)) return defined def _rule_undefined_token(html: str, dom: _DOM, fragment: bool, out: list): defined = _defined_tokens(html, dom) seen = set() for m in _VAR_RE.finditer(html): name = m.group(1).lower() if name not in defined and name not in seen: seen.add(name) out.append(Finding("undefined-token", "error", f"var(--{name}) references an undefined token", "typo, or a token that isn't in the palette / not declared here")) def _style_strings(dom: _DOM): for node in dom.nodes: st = node.attrs.get("style") if st: yield node, st def _rule_hardcoded_color(html: str, dom: _DOM, fragment: bool, out: list): reported = 0 for node, st in _style_strings(dom): if _HEX_RE.search(st) or _FUNC_COLOR_RE.search(st): reported += 1 if reported <= 8: out.append(Finding("hardcoded-color", "warn", f"literal colour in style= on <{node.tag}>", "use a palette token, e.g. var(--clay), var(--g500)")) if reported > 8: out.append(Finding("hardcoded-color", "warn", f"...and {reported - 8} more literal-colour style attributes")) def _rule_inline_typography(html: str, dom: _DOM, fragment: bool, out: list): for node, st in _style_strings(dom): if _FONT_FAMILY_RE.search(st): out.append(Finding("inline-typography", "warn", f"font-family in style= on <{node.tag}>", "use the type stacks: var(--serif), var(--sans), var(--mono)")) if _FONT_SIZE_RE.search(st): out.append(Finding("inline-typography", "warn", f"font-size in style= on <{node.tag}>", "prefer the heading scale / existing type sizes")) def _rule_nested_card(html: str, dom: _DOM, fragment: bool, out: list): for node in dom.nodes: if "card" in node.classes: if any("card" in a.classes for a in _ancestors(node)): out.append(Finding("nested-card", "warn", f"<{node.tag} class=card> nested inside another .card", "card-in-card; flatten or use a plain container")) def _rule_broken_tabs(html: str, dom: _DOM, fragment: bool, out: list): targets = [(n, n.attrs.get("data-target")) for n in dom.nodes if n.attrs.get("data-target")] panel_ids = {n.attrs.get("data-id") for n in dom.nodes if "tab-panel" in n.classes} panel_ids.discard(None) target_vals = {t for _, t in targets} for node, t in targets: if t not in panel_ids: out.append(Finding("broken-tabs", "error", f'data-target="{t}" has no matching .tab-panel[data-id="{t}"]', "tab button wired to a missing panel")) for n in dom.nodes: if "tab-panel" in n.classes: pid = n.attrs.get("data-id") if pid and pid not in target_vals: out.append(Finding("broken-tabs", "warn", f'.tab-panel[data-id="{pid}"] has no button targeting it', "orphan panel — unreachable")) def _rule_broken_bind(html: str, dom: _DOM, fragment: bool, out: list): binds = {n.attrs.get("data-bind") for n in dom.nodes if n.attrs.get("data-bind")} outs = {n.attrs.get("data-out") for n in dom.nodes if n.attrs.get("data-out")} var_binds = {m.group(1)[5:] for m in _VAR_RE.finditer(html) if m.group(1).lower().startswith("bind-")} for b in binds: if b not in outs and b not in var_binds: out.append(Finding("broken-bind", "warn", f'data-bind="{b}" has no consumer', f'no [data-out="{b}"] and no var(--bind-{b})')) for o in outs: if o not in binds: out.append(Finding("broken-bind", "warn", f'[data-out="{o}"] has no matching data-bind="{o}"', "output element will never update")) def _rule_broken_sortable(html: str, dom: _DOM, fragment: bool, out: list): for node in dom.nodes: if "data-sortable" in node.attrs: # descendants stack = list(node.children) has_draggable = False while stack: cur = stack.pop() if (cur.attrs.get("draggable") or "").lower() == "true": has_draggable = True break stack.extend(cur.children) if not has_draggable: out.append(Finding("broken-sortable", "warn", f"<{node.tag} data-sortable> has no draggable children", 'add draggable="true" to the reorderable items')) def _rule_heading_skip(html: str, dom: _DOM, fragment: bool, out: list): prev = None for node in dom.nodes: if len(node.tag) == 2 and node.tag[0] == "h" and node.tag[1].isdigit(): lvl = int(node.tag[1]) if prev is not None and lvl > prev + 1: out.append(Finding("heading-skip", "warn", f"heading jumps h{prev} -> h{lvl}", "don't skip heading levels")) prev = lvl def _rule_img_no_alt(html: str, dom: _DOM, fragment: bool, out: list): for node in dom.nodes: if node.tag == "img" and "alt" not in node.attrs: out.append(Finding("img-no-alt", "warn", "<img> without alt attribute", 'add alt="" (empty for decorative)')) _RULES = [ _rule_chrome_leak, _rule_undefined_token, _rule_hardcoded_color, _rule_inline_typography, _rule_nested_card, _rule_broken_tabs, _rule_broken_bind, _rule_broken_sortable, _rule_heading_skip, _rule_img_no_alt, ] # --------------------------------------------------------------------------- # # public # # --------------------------------------------------------------------------- # def check_html(html: str, *, fragment: bool | None = None) -> list[Finding]: if fragment is None: fragment = _is_fragment(html) dom = _parse(html) out: list[Finding] = [] for rule in _RULES: rule(html, dom, fragment, out) return out def format_findings(findings: list[Finding]) -> str: if not findings: return "OK — no findings." errs = sum(1 for f in findings if f.severity == "error") warns = sum(1 for f in findings if f.severity == "warn") lines = [str(f) for f in findings] lines.append(f"\n{errs} error(s), {warns} warning(s).") return "\n".join(lines) def has_errors(findings: list[Finding]) -> bool: return any(f.severity == "error" for f in findings) -
composer.py 10.3 KB
"""composing-html / composer.py Page shell + primitive helpers shared by every template module. Templates receive a structured spec (a dict) and return a single HTML string that goes into <body>. The composer wraps it with <head>, inlines base.css / base.js. If a caller passes `colophon=`, adds a colophon footer. Templates never write <html>, <head>, <style>, <script>, or <link>. Helpers provided here are deliberately small. Each takes plain Python data (strings, dicts, lists) and returns an HTML string. Nothing fancy — keep the output readable when someone opens the file in DevTools. """ from __future__ import annotations import html import json import re from collections.abc import Iterable from pathlib import Path from typing import Any ASSETS = Path(__file__).resolve().parent.parent / "assets" # --------------------------------------------------------------------------- # # escaping # # --------------------------------------------------------------------------- # def esc(value: Any) -> str: """Escape for text content. Lists are joined with spaces. None → ''.""" if value is None: return "" if isinstance(value, (list, tuple)): return " ".join(esc(v) for v in value) return html.escape(str(value), quote=True) def attrs(mapping: dict | None) -> str: """Render a dict of attributes. Drops None / False; True → bare attr. Keys with underscores are translated to hyphens so Python kwargs like ``data_target`` become ``data-target``. """ if not mapping: return "" parts = [] for k, v in mapping.items(): if v is None or v is False: continue key = k.replace("_", "-") if v is True: parts.append(key) else: parts.append(f'{key}="{html.escape(str(v), quote=True)}"') return (" " + " ".join(parts)) if parts else "" # --------------------------------------------------------------------------- # # primitives # # --------------------------------------------------------------------------- # def tag(name: str, inner: str = "", **attributes: Any) -> str: return f"<{name}{attrs(attributes)}>{inner}</{name}>" def void(name: str, **attributes: Any) -> str: return f"<{name}{attrs(attributes)}>" def eyebrow(text: str) -> str: return f'<div class="eyebrow">{esc(text)}</div>' if text else "" def badge(label: str, kind: str | None = None) -> str: cls = "badge" + (f" badge--{kind}" if kind else "") return f'<span class="{cls}">{esc(label)}</span>' def kbd(key: str) -> str: return f'<span class="kbd">{esc(key)}</span>' def code_block(source: str, lang: str | None = None) -> str: cls = f' class="lang-{esc(lang)}"' if lang else "" return f'<pre><code{cls}>{esc(source)}</code></pre>' def inline_code(source: str) -> str: return f"<code>{esc(source)}</code>" def section(title: str | None = None, body: str = "", id: str | None = None, wide: bool = False) -> str: head = "" if title: head = f'<h2>{esc(title)}</h2><hr class="rule">' sec_attrs = attrs({"id": id, "class": "wide" if wide else None}) return f"<section{sec_attrs}>{head}{body}</section>" def card(body: str, title: str | None = None, *, soft: bool = False, elev: bool = False, extra_class: str = "") -> str: classes = ["card"] if soft: classes.append("card--soft") if elev: classes.append("card--elev") if extra_class: classes.append(extra_class) head = f"<h3>{esc(title)}</h3>" if title else "" return f'<div class="{" ".join(classes)}">{head}{body}</div>' def grid(items: Iterable[str], cols: int | str = "auto", gap: int = 20) -> str: cls = f"grid grid--{cols}" style = f"gap:{gap}px;" return f'<div class="{cls}" style="{style}">{"".join(items)}</div>' def stack(items: Iterable[str], gap: int = 16) -> str: return f'<div class="stack" style="gap:{gap}px;">{"".join(items)}</div>' def row(items: Iterable[str], gap: int = 12) -> str: return f'<div class="row" style="gap:{gap}px;">{"".join(items)}</div>' def bullets(items: Iterable[str]) -> str: return '<ul class="bullets">' + "".join(f"<li>{esc(i)}</li>" for i in items) + "</ul>" def kv_list(pairs: dict[str, Any]) -> str: """Definition-list-style key/value rendering.""" rows = "".join( f'<div style="display:flex;gap:12px;padding:8px 0;border-bottom:1px solid var(--g150);">' f'<div style="font-family:var(--mono);font-size:12px;color:var(--g500);min-width:140px;">{esc(k)}</div>' f'<div>{esc(v)}</div></div>' for k, v in pairs.items() ) return f'<div>{rows}</div>' def raw(html_string: str) -> _Raw: """Wrap a string to opt out of HTML escaping in `table` and `callout` cells. Use this when a cell genuinely needs to contain HTML markup the caller constructed (e.g. a `badge()` result). Plain strings are always escaped. """ return _Raw(html_string) class _Raw(str): """Marker subclass for opt-in raw HTML in cells. Treated as str everywhere except composer functions that switch on `isinstance(x, _Raw)`.""" __slots__ = () def _cell(value: Any) -> str: return str(value) if isinstance(value, _Raw) else esc(value) def table(columns: list[str], rows: list[list[Any]]) -> str: head = "<thead><tr>" + "".join(f"<th>{esc(c)}</th>" for c in columns) + "</tr></thead>" body = "<tbody>" + "".join( "<tr>" + "".join(f"<td>{_cell(cell)}</td>" for cell in r) + "</tr>" for r in rows ) + "</tbody>" return f"<table>{head}{body}</table>" def details(summary: str, body: str, *, open: bool = False) -> str: return f'<details{" open" if open else ""}><summary>{esc(summary)}</summary>{body}</details>' def tabs(panels: list[dict[str, Any]]) -> str: """panels: [{id, label, html}]""" btns = "".join(f'<button data-target="{esc(p["id"])}">{esc(p["label"])}</button>' for p in panels) body = "".join(f'<div class="tab-panel" data-id="{esc(p["id"])}">{p["html"]}</div>' for p in panels) return f'<div class="tabgroup"><div class="tabs">{btns}</div>{body}</div>' def callout(text: Any, kind: str = "info", icon: str | None = None) -> str: """Render an info/ok/warn/err callout box. Pass `raw(html)` to opt out of escaping.""" color = {"info": "var(--info)", "ok": "var(--ok)", "warn": "var(--warn)", "err": "var(--err)"}.get(kind, "var(--info)") bg = {"info": "#DCE7EE", "ok": "#E8EFE0", "warn": "#F8ECCB", "err": "#F4DAD5"}.get(kind, "#DCE7EE") icon_html = f'<span style="font-weight:700;">{esc(icon)}</span>' if icon else "" return (f'<div style="border-left:3px solid {color};background:{bg};padding:12px 16px;' f'border-radius:0 var(--radius-sm) var(--radius-sm) 0;display:flex;gap:10px;align-items:flex-start;">' f'{icon_html}<div>{_cell(text)}</div></div>') # --------------------------------------------------------------------------- # # page shell # # --------------------------------------------------------------------------- # def page(*, title: str, body: str, subtitle: str | None = None, eyebrow_text: str | None = None, page_class: str = "page", body_attrs: dict | None = None, extra_css: str = "", extra_js: str = "", show_masthead: bool = True, colophon: str | None = None) -> str: """Wrap inner body html with the full page shell. Inlines base.css and base.js so the artifact is a single, portable file. Templates can pass extra_css / extra_js for one-off rules. """ base_css = (ASSETS / "base.css").read_text(encoding="utf-8") base_js = (ASSETS / "base.js").read_text(encoding="utf-8") masthead_html = "" if show_masthead and (title or subtitle or eyebrow_text): parts = [] if eyebrow_text: parts.append(f'<div class="eyebrow">{esc(eyebrow_text)}</div>') if title: parts.append(f'<h1>{esc(title)}</h1>') if subtitle: parts.append(f'<p class="subtitle">{esc(subtitle)}</p>') masthead_html = f'<header class="masthead">{"".join(parts)}</header>' colo_html = (f'<div class="colophon"><span>{esc(colophon)}</span>' f'<span></span></div>') if colophon else "" body_attr_str = attrs(body_attrs) return ( "<!doctype html>\n" '<html lang="en">\n' '<head>\n' '<meta charset="utf-8">\n' '<meta name="viewport" content="width=device-width,initial-scale=1">\n' f'<title>{esc(title or "Untitled")}</title>\n' '<style>\n' + base_css + '\n' + (extra_css and ("\n/* template extras */\n" + extra_css + "\n") or "") + '</style>\n' '</head>\n' f'<body{body_attr_str}>\n' f'<main class="{page_class}">' f'{masthead_html}' f'{body}' f'{colo_html}' f'</main>\n' '<script>\n' + base_js + '\n' + (extra_js and ("\n/* template extras */\n" + extra_js + "\n") or "") + '</script>\n' '</body>\n' '</html>\n' ) # --------------------------------------------------------------------------- # # helpers # # --------------------------------------------------------------------------- # _CSS_COLOR_RE = re.compile( r"^(" r"#[0-9a-fA-F]{3,8}" # hex r"|rgba?\([^)]+\)" # rgb / rgba r"|hsla?\([^)]+\)" # hsl / hsla r"|var\(--[a-zA-Z0-9_-]+(?:\s*,\s*[#a-zA-Z0-9.,()% _-]+)?\)" # var(--token[, fallback]) r"|[a-zA-Z]{3,30}" # named: 'red', 'transparent', 'currentColor' r")$" ) def css_color(value: Any, default: str = "var(--g500)") -> str: """Whitelist a CSS color value. Anything that doesn't look like a color falls back to `default`. Use this for any color taken from a spec field.""" if not isinstance(value, str): return default v = value.strip() return v if _CSS_COLOR_RE.match(v) else default def slug(text: str) -> str: s = re.sub(r"[^a-zA-Z0-9]+", "-", str(text)).strip("-").lower() return s or "x" def json_dump(obj: Any) -> str: return json.dumps(obj, ensure_ascii=False, indent=2)
-
-
tests
-
test_checker.py 5.5 KB
"""Tests for composing-html's deterministic checker. Run with: python composing-html/tests/test_checker.py (no pytest needed) or python -m pytest composing-html/tests -q """ from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "scripts")) from checker import check_html, has_errors def _rules(html, **kw): return {f.rule for f in check_html(html, **kw)} _PASS = 0 _FAIL = 0 def ok(cond, label): global _PASS, _FAIL if cond: _PASS += 1 else: _FAIL += 1 print(f" FAIL: {label}") # --- clean fragment: no findings ------------------------------------------- # clean = """ <section> <div class="eyebrow">OVERVIEW</div> <h2>Status</h2> <div class="grid grid--2"> <div class="card"><p style="color: var(--clay)">All good</p></div> <div class="card"><span class="badge badge--ok">v1</span></div> </div> <img src="x.png" alt="diagram"> </section> """ ok(_rules(clean, fragment=True) == set(), "clean fragment yields no findings") ok(not has_errors(check_html(clean, fragment=True)), "clean fragment has no errors") # --- chrome leak (fragment only) ------------------------------------------- # ok("chrome-leak" in _rules('<head><link rel="x"></head><p>hi</p>', fragment=True), "chrome-leak fires on <head>/<link> in fragment") ok("chrome-leak" not in _rules('<!doctype html><html><head></head><body>x</body></html>'), "chrome-leak silent on a full artifact") # --- undefined token ------------------------------------------------------- # ok("undefined-token" in _rules('<p style="color: var(--cley)">typo</p>', fragment=True), "undefined-token catches var(--cley) typo") ok("undefined-token" not in _rules('<p style="color: var(--clay)">ok</p>', fragment=True), "undefined-token silent on valid token") ok("undefined-token" not in _rules( '<input data-bind="size"><span style="width: var(--bind-size)"></span>', fragment=True), "undefined-token silent on --bind-* created by data-bind") ok("undefined-token" not in _rules( '<div style="--mytok: 4px; padding: var(--mytok)"></div>', fragment=True), "undefined-token silent on author-declared custom property") # --- hardcoded colour ------------------------------------------------------ # ok("hardcoded-color" in _rules('<p style="color:#ff0000">red</p>', fragment=True), "hardcoded-color catches hex literal") ok("hardcoded-color" in _rules('<p style="background: rgb(1,2,3)">x</p>', fragment=True), "hardcoded-color catches rgb()") # --- inline typography ----------------------------------------------------- # ok("inline-typography" in _rules('<p style="font-family: Inter">x</p>', fragment=True), "inline-typography catches font-family") ok("inline-typography" in _rules('<p style="font-size: 22px">x</p>', fragment=True), "inline-typography catches font-size") # --- nested card ----------------------------------------------------------- # ok("nested-card" in _rules('<div class="card"><div class="card">x</div></div>', fragment=True), "nested-card catches card-in-card") ok("nested-card" not in _rules('<div class="card">x</div><div class="card">y</div>', fragment=True), "nested-card silent on sibling cards") ok("nested-card" not in _rules('<div class="scorecard"><div class="card">x</div></div>', fragment=True), "nested-card does not match substring 'scorecard'") # --- broken tabs ----------------------------------------------------------- # broken_tabs = '<div class="tabs"><button data-target="a">A</button></div>' ok("broken-tabs" in _rules(broken_tabs, fragment=True), "broken-tabs catches target with no panel") good_tabs = ('<div class="tabs"><button data-target="a">A</button></div>' '<div class="tab-panel" data-id="a">x</div>') ok("broken-tabs" not in _rules(good_tabs, fragment=True), "broken-tabs silent on wired tabs") # --- broken bind ----------------------------------------------------------- # ok("broken-bind" in _rules('<input data-bind="size">', fragment=True), "broken-bind catches bind with no consumer") ok("broken-bind" in _rules('<span data-out="ghost"></span>', fragment=True), "broken-bind catches orphan data-out") # --- broken sortable ------------------------------------------------------- # ok("broken-sortable" in _rules('<div data-sortable="true"><div>x</div></div>', fragment=True), "broken-sortable catches no draggable children") ok("broken-sortable" not in _rules( '<div data-sortable="true"><div draggable="true">x</div></div>', fragment=True), "broken-sortable silent when draggable present") # --- heading skip ---------------------------------------------------------- # ok("heading-skip" in _rules('<h1>a</h1><h3>b</h3>', fragment=True), "heading-skip catches h1->h3") ok("heading-skip" not in _rules('<h1>a</h1><h2>b</h2><h3>c</h3>', fragment=True), "heading-skip silent on proper nesting") # --- img no alt ------------------------------------------------------------ # ok("img-no-alt" in _rules('<img src="x.png">', fragment=True), "img-no-alt catches missing alt") ok("img-no-alt" not in _rules('<img src="x.png" alt="">', fragment=True), "img-no-alt silent on empty alt") # --- severity contract ----------------------------------------------------- # ok(has_errors(check_html('<p style="color: var(--nope)">x</p>', fragment=True)), "undefined-token is error severity") ok(not has_errors(check_html('<img src=x>', fragment=True)), "img-no-alt alone is not error severity") print(f"\n{_PASS} passed, {_FAIL} failed") sys.exit(1 if _FAIL else 0) -
test_smoke.py 17.7 KB
"""Smoke + regression tests for composing-html. Run with: python -m pytest composing-html/tests -q or python composing-html/tests/test_smoke.py (no pytest needed) Covers every template builder with a representative spec, then asserts each output: - starts with `<!doctype html>` - parses cleanly with html.parser - contains the expected content marker - does NOT contain raw `<script>alert` or `<img onerror=` injections from spec-side strings (regression for the deleted `_looks_like_html` heuristic) """ from __future__ import annotations import html.parser as hp import json import sys import types from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "scripts")) from composer import callout, css_color, page, raw, table from templates import REGISTRY # --------------------------------------------------------------------------- # # helpers # # --------------------------------------------------------------------------- # def _parses(html_str: str) -> bool: try: hp.HTMLParser().feed(html_str); return True except Exception: return False def _build(name: str, spec: dict) -> str: assert name in REGISTRY, f"template {name!r} not registered" return page(**REGISTRY[name]["build"](spec)) # --------------------------------------------------------------------------- # # representative specs (exercise the interesting branches of each template) # # --------------------------------------------------------------------------- # SPECS: dict[str, dict] = { "freeform": { "title": "ff", "body_html": "<section><h2>Hi</h2><p>x</p></section>", }, "exploration.comparison_grid": { "title": "Compare", "recommendation": "A", "options": [ {"name": "A", "verdict": "best", "summary": "s", "pros": ["p1"], "cons": ["c1"], "tags": ["t"]}, {"name": "B", "summary": "s2"}, ], }, "exploration.design_directions": { "title": "Dir", "directions": [ {"name": "D1", "vibe": "v", "palette": ["#FAF9F5", "#D97757"], "typography": {"display": "Aa", "body": "x"}, "notes": "n"}, ], }, "exploration.implementation_plan": { "title": "Plan", "data_flow": ["A", "B"], "milestones": [ {"name": "m", "when": "wk1", "owner": "alex", "status": "active", "deliverables": ["d"]}], "risks": [{"risk": "r", "likelihood": "med", "impact": "high", "mitigation": "m"}], }, "review.pr_review": { "title": "PR", "pr": {"repo": "r", "number": 1, "branch": "b", "author": "a", "additions": 1, "deletions": 1}, "verdict": "approve", "summary_html": "<p>s</p>", "findings": [{"file": "x.py", "line": 1, "severity": "warn", "title": "t", "body_html": "<p>b</p>", "snippet": "x = 1", "snippet_lang": "python"}], }, "review.code_walkthrough": { "title": "Walk", "steps": [{"heading": "h", "body_html": "<p>b</p>", "code": "x=1", "lang": "python"}], }, "review.module_map": { "title": "Map", "nodes": [{"id": "a", "label": "A", "kind": "core"}, {"id": "b", "label": "B", "kind": "util"}], "edges": [{"from": "a", "to": "b", "label": "calls"}], "legend": [{"label": "core", "kind": "core"}], }, "design.design_system": { "title": "DS", "colors": {"Brand": [{"name": "Clay", "hex": "#D97757", "token": "--clay"}]}, "typography": [{"name": "Body", "font_family": "var(--sans)", "size": "16px", "weight": "400"}], "spacing": [{"name": "sm", "px": 8}], "radii": [{"name": "sm", "px": 4}], }, "design.component_variants": { "title": "CV", "components": [{ "name": "Button", "axes": {"Size": ["sm", "md"], "Intent": ["primary"]}, "cells": [{"coords": {"Size": "sm", "Intent": "primary"}, "html": "<button>x</button>"}], }], }, "prototype.animation_sandbox": { "title": "Sandbox", "params": [{"name": "d", "label": "Dur", "type": "range", "min": 0, "max": 1000, "default": 300, "unit": "ms"}], "preview_html": "<div></div>", }, "prototype.click_flow": { "title": "Flow", "screens": [ {"id": "a", "label": "A", "html": "<p>A</p>", "transitions": [{"label": "→", "to_id": "b"}]}, {"id": "b", "label": "B", "html": "<p>B</p>"}, ], }, "diagram.svg_figure_sheet": { "title": "Figs", "figures": [{"label": "F1", "caption": "c", "svg": "<svg viewBox='0 0 10 10'><rect width='10' height='10'/></svg>"}], }, "diagram.flowchart": { "title": "Flow", "orientation": "vertical", "steps": [{"id": "s1", "label": "Start", "kind": "start"}, {"id": "s2", "label": "End", "kind": "end"}], }, "deck.slide_deck": { "title": "Deck", "slides": [{"kind": "title", "title": "T"}, {"kind": "content", "title": "C", "body_html": "<p>x</p>"}], }, "research.feature_explainer": { "title": "Feat", "sections": [{"id": "a", "heading": "A", "body_html": "<p>x</p>", "code_tabs": [{"label": "py", "code": "x=1", "lang": "python"}]}], }, "research.concept_explainer": { "title": "Concept", "body_html": "<p>x</p>", "glossary": [{"term": "t", "definition_html": "<code>v</code>"}], }, "report.status_report": { "title": "Status", "metrics": [{"label": "X", "value": "1", "delta": "+1", "kind": "ok"}], "shipped": [{"title": "s", "owner": "o"}], "in_flight": [{"title": "i", "progress": 0.5}], "blocked": [{"title": "b"}], }, "report.incident_report": { "title": "INC", "severity": "sev2", "duration": "30m", "impact_html": "<p>i</p>", "summary_html": "<p>s</p>", "timeline": [{"at": "14:00", "event": "e", "kind": "detected"}], "root_cause_html": "<p>rc</p>", "followups": [{"title": "f", "owner": "o", "due": "d", "status": "open"}], }, "editor.triage_board": { "title": "Board", "columns": [{"id": "todo", "label": "Todo", "color": "info", "items": [{"id": "i1", "title": "x", "tags": ["P1"]}]}], }, "editor.flag_editor": { "title": "Flags", "flags": [{"id": "a", "label": "A", "default": True}, {"id": "b", "label": "B", "default": False, "requires": ["a"]}], }, "editor.prompt_tuner": { "title": "Tuner", "template": "Hi {{name}}", "variables": [{"name": "name", "default": "x"}], }, } # --------------------------------------------------------------------------- # # tests # # --------------------------------------------------------------------------- # def test_every_template_has_a_spec(): missing = sorted(set(REGISTRY) - set(SPECS)) assert not missing, f"specs missing for: {missing}" def test_every_template_renders_and_parses(): failures = [] for name, spec in SPECS.items(): try: html = _build(name, spec) assert html.startswith("<!doctype html>"), f"{name}: no doctype" assert "</html>" in html, f"{name}: no </html>" assert _parses(html), f"{name}: parser raised" except Exception as e: failures.append((name, str(e))) assert not failures, "\n".join(f"{n}: {e}" for n, e in failures) # --- security regressions ------------------------------------------------- # INJECT = "<script>alert(1)</script><img src=x onerror=alert(1)>" def test_table_does_not_emit_raw_script(): out = table(["x"], [[INJECT]]) assert "<script>" not in out assert "<script>" in out def test_callout_escapes_plain_strings(): out = callout(INJECT) assert "<script>" not in out assert "<script>" in out def test_table_raw_opt_in_works(): out = table(["x"], [[raw("<b>ok</b>")]]) assert "<b>ok</b>" in out def test_implementation_plan_owner_attr_is_quoted(): out = _build("exploration.implementation_plan", { "title": "p", "milestones": [{"name": "m", "when": "wk1", "owner": "alex", "deliverables": ["d"]}], }) # Owner div must use a quoted style attribute, not `style=font-...`. assert 'style="font-family:var(--mono)' in out assert 'style=font-family' not in out def test_prompt_tuner_defends_against_script_breakout(): out = _build("editor.prompt_tuner", { "title": "x", "template": "before </script><img src=x onerror=alert(1)> after", "variables": [{"name": "x", "default": ""}], }) # Inside the inlined JS string literal, </ must be escaped to <\/. # The plain literal `</script>` must not appear except as the closing tag # of the actual <script> we emit ourselves. closing_tags = out.count("</script>") assert closing_tags == 1, ( f"expected exactly one </script> (the real closer); found {closing_tags}" ) def test_flag_editor_escapes_id_in_attrs(): out = _build("editor.flag_editor", { "title": "f", "flags": [{"id": 'a"b', "label": "L", "default": True, "requires": ['x"y']}], }) # The injected " must be HTML-escaped inside both the data-id and # data-requires attributes; the surrounding attribute uses double quotes. assert 'data-id="a"b"' in out assert """ in out # JSON " inside data-requires is escaped def test_flag_editor_handles_missing_id(): out = _build("editor.flag_editor", { "title": "f", "flags": [{"label": "no-id", "default": False}], }) assert "</html>" in out # no KeyError def test_module_map_does_not_mutate_spec(): spec = {"nodes": [{"id": "a", "label": "A"}, {"id": "b", "label": "B"}], "edges": [{"from": "a", "to": "b"}]} snap = json.dumps(spec, sort_keys=True) _build("review.module_map", spec) assert json.dumps(spec, sort_keys=True) == snap, ( "module_map mutated the caller's spec — nodes still hold injected x/y" ) def test_module_map_warns_on_unknown_edge(capsys): spec = {"nodes": [{"id": "a", "label": "A"}], "edges": [{"from": "a", "to": "missing"}]} _build("review.module_map", spec) err = capsys.readouterr().err assert "unknown id" in err or "missing" in err def test_slide_deck_raises_on_unknown_kind(): try: _build("deck.slide_deck", {"slides": [{"kind": "contant", "title": "x"}]}) except ValueError as e: assert "unknown slide kind" in str(e) else: raise AssertionError("expected ValueError") def test_css_color_rejects_injection(): assert css_color("#D97757") == "#D97757" assert css_color("var(--clay)") == "var(--clay)" assert css_color("rgb(217,119,87)") == "rgb(217,119,87)" assert css_color("red;background-image:url(http://x)") != "red;background-image:url(http://x)" assert css_color("javascript:alert(1)") != "javascript:alert(1)" assert css_color("transparent") == "transparent" def test_describe_emits_valid_json(): """Regression for #8 — the printed skeleton must round-trip via json.loads.""" import contextlib import io import re import build as _build_mod class _Args: pass for name in REGISTRY: a = _Args(); a.template = name buf = io.StringIO() with contextlib.redirect_stdout(buf): _build_mod.cmd_describe(a) out = buf.getvalue() m = re.search(r"```json\n(.*?)\n```", out, re.DOTALL) assert m, f"{name}: no JSON block in describe output" json.loads(m.group(1)) # raises if invalid # --------------------------------------------------------------------------- # # --set CLI override behavior # # --------------------------------------------------------------------------- # def test_apply_set_inline_value(): from build import _apply_set spec = {} rc = _apply_set(spec, ["title=Hello", "subtitle=World"]) assert rc == 0 assert spec == {"title": "Hello", "subtitle": "World"} def test_apply_set_preserves_equals_in_value(): """KEY=VALUE uses str.partition('='), so '=' inside the value survives.""" from build import _apply_set spec = {} rc = _apply_set(spec, ["extra_css=.x { content: 'a=b'; }"]) assert rc == 0 assert spec["extra_css"] == ".x { content: 'a=b'; }" def test_apply_set_loads_file_with_at_prefix(): """KEY=@FILE loads the file's raw contents — newlines, quotes, all OK.""" import tempfile from build import _apply_set with tempfile.TemporaryDirectory() as td: p = Path(td) / "body.html" body = '<section>\n "multi-line" & <em>special</em> content\n</section>' p.write_text(body, encoding="utf-8") spec = {} rc = _apply_set(spec, [f"body_html=@{p}"]) assert rc == 0 assert spec["body_html"] == body # The whole point: content can include newlines and quotes that # would otherwise need careful JSON-string escaping. assert "\n" in spec["body_html"] assert '"' in spec["body_html"] def test_apply_set_overrides_existing_field(): from build import _apply_set spec = {"title": "from-json", "extra_css": "/* old */"} rc = _apply_set(spec, ["title=from-set"]) assert rc == 0 assert spec == {"title": "from-set", "extra_css": "/* old */"} def test_apply_set_rejects_bad_syntax(): from build import _apply_set spec = {} rc = _apply_set(spec, ["no_equals_sign"]) assert rc == 2 rc = _apply_set({}, ["=missing-key"]) assert rc == 2 def test_apply_set_rejects_missing_file(): from build import _apply_set spec = {} rc = _apply_set(spec, ["body_html=@/tmp/definitely-does-not-exist-xyz.html"]) assert rc == 2 def test_cli_build_with_only_set_and_no_spec(): """End-to-end: --set alone (no --spec) renders a valid page.""" import subprocess import tempfile cli = ROOT / "scripts" / "build.py" with tempfile.TemporaryDirectory() as td: body = Path(td) / "body.html" body.write_text( '<section><h2>Multi-line</h2>\n<p>Has "quotes" & <em>tags</em>.</p></section>', encoding="utf-8", ) out = Path(td) / "out.html" result = subprocess.run( [sys.executable, str(cli), "build", "freeform", "--set", "title=No-spec demo", "--set", f"body_html=@{body}", "--out", str(out)], capture_output=True, text=True, ) assert result.returncode == 0, f"CLI failed: {result.stderr}" rendered = out.read_text(encoding="utf-8") assert "<!doctype html>" in rendered.lower() assert "Multi-line" in rendered assert '"quotes"' in rendered assert "No-spec demo" in rendered # title made it through def test_cli_build_set_overrides_spec_file(): """When --spec and --set both supply the same key, --set wins.""" import subprocess import tempfile cli = ROOT / "scripts" / "build.py" with tempfile.TemporaryDirectory() as td: spec = Path(td) / "spec.json" spec.write_text( json.dumps({"title": "from-spec", "body_html": "<p>spec body</p>"}), encoding="utf-8", ) body = Path(td) / "body.html" body.write_text("<p>set body wins</p>", encoding="utf-8") out = Path(td) / "out.html" result = subprocess.run( [sys.executable, str(cli), "build", "freeform", "--spec", str(spec), "--set", f"body_html=@{body}", "--out", str(out)], capture_output=True, text=True, ) assert result.returncode == 0, f"CLI failed: {result.stderr}" rendered = out.read_text(encoding="utf-8") assert "set body wins" in rendered assert "spec body" not in rendered assert "from-spec" in rendered # title from spec was kept # --------------------------------------------------------------------------- # # direct entrypoint # # --------------------------------------------------------------------------- # def _run_directly() -> int: """Plain runner so the suite works without pytest installed.""" failed = 0 fns = [v for k, v in globals().items() if k.startswith("test_") and isinstance(v, types.FunctionType)] for fn in fns: # Stub `capsys` for the one test that uses it. import io import sys as _sys if "capsys" in fn.__code__.co_varnames: buf_err = io.StringIO() old_err = _sys.stderr _sys.stderr = buf_err try: class _CS: # minimal capsys stand-in def readouterr(self_inner, _buf=buf_err): return types.SimpleNamespace(out="", err=_buf.getvalue()) fn(_CS()) print(f" ✓ {fn.__name__}") except AssertionError as e: failed += 1; print(f" ✗ {fn.__name__}: {e}") finally: _sys.stderr = old_err else: try: fn(); print(f" ✓ {fn.__name__}") except AssertionError as e: failed += 1; print(f" ✗ {fn.__name__}: {e}") except Exception as e: failed += 1; print(f" ✗ {fn.__name__}: {type(e).__name__}: {e}") print(f"\n{len(fns) - failed}/{len(fns)} passed") return 1 if failed else 0 if __name__ == "__main__": raise SystemExit(_run_directly())
-
-
.gitignore 19 B · in bundle
-
CHANGELOG.md 4.4 KB
# composing-html - Changelog All notable changes to the `composing-html` skill are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [0.5.0] - 2026-06-04 ### Other - composing-html v0.5.0: deterministic check command + rule anchors (#684) ## [0.5.0] - 2026-06-03 ### Added - `build.py check <file.html>` — a deterministic structural linter (no model call, stdlib only) for built artifacts and body fragments. Calibrated to composing-html's actual failure modes: content that breaks *out* of the fixed design system (`hardcoded-color`, `inline-typography`, `undefined-token`) or miswires `base.js` hooks (`broken-tabs`, `broken-bind`, `broken-sortable`), plus `chrome-leak`, `nested-card`, `heading-skip`, `img-no-alt`. Error-severity rules set a non-zero exit code; `--json` for machine output; full-artifact vs fragment auto-detected (`--full` / `--fragment` to force). Contrast checking intentionally out of scope (token pairs are pre-vetted; regex can't judge custom pairs). - `scripts/checker.py` module exposing `check_html()` / `format_findings()`. - `tests/test_checker.py` — one fire/silent assertion pair per rule. - SKILL.md: a "Checking output" section, and `<!-- rule:ID -->` anchors on the output rules tying each guidance line to its checker rule, so teaching and enforcement stay in sync. (Approach adapted from pbakaus/impeccable's rule-anchored guidance, retargeted from freehand-design taste to system-adherence + hook integrity.) ## [0.4.1] - 2026-05-21 ### Other - Revert #665 — move HTML routing out of composing-html (v0.4.1) (#666) ## [0.4.1] - 2026-05-21 ### Removed - "Sibling skill: Hallmark" routing section and the `austegard.com` / `muninn.austegard.com` exclusion clause added in 0.4.0. The routing decision (and the personal-site exclusions) is operator-specific and doesn't belong in a general-purpose skill. Moved to a separate router skill in `oaustegard/muninn-utilities` (`skills/designing-html/`). - Description reverted to the 0.3.0 phrasing — no pointer at Hallmark or any external sibling skill. ## [0.4.0] - 2026-05-21 ### Other - composing-html: route ad-hoc HTML to Hallmark sibling skill (v0.4.0) (#665) ## [0.4.0] - 2026-05-21 ### Added - SKILL.md "Sibling skill: Hallmark for greenfield ad-hoc HTML" section routing greenfield landing-page / audit / redesign / `study` briefs to the Hallmark skill at `oaustegard/fork-hallmark` (MIT, fork of `Nutlope/hallmark`). Includes a brief / use-which table and an explicit exclusion for `muninn.austegard.com` and `austegard.com` (established voices; composing-html and site-native templates handle them). - Description updated to point ad-hoc HTML cases at the new section. ## [0.3.0] - 2026-05-20 ### Other - composing-html: --set escape hatch for body_html (fix multi-line JSON failure mode) (#657) ## [0.3.0] - 2026-05-19 ### Added - `build.py build` now accepts `--set KEY=VALUE` and `--set KEY=@FILE` to assign or override spec fields from the command line. `@FILE` loads the file contents verbatim into the field, sidestepping JSON-string escaping for multi-line HTML / CSS / JS bodies. `--spec` is now optional when `--set` supplies every required field. `--set` overrides matching fields from `--spec`. - Tests cover the new `--set` paths (inline values, file loads, override semantics, bad syntax, missing files) and end-to-end CLI invocation. ### Changed - SKILL.md reframes the freeform workflow around `--set body_html=@body.html` as the recommended path for anything with a substantial body — the prior "write spec.json" instruction was prone to producing invalid JSON when models inlined multi-line HTML via heredoc. The spec-file path is now positioned as best-fit for templates with typed slots, where JSON earns its keep. ## [0.2.0] - 2026-05-09 ### Other - composing-html: lead with chrome + freeform; demote templates (#636) (#637) ## [0.2.0] - 2026-05-09 ### Changed - Reframe SKILL.md around chrome + `freeform` as the default workflow; demote templates to a "shortcuts for repeat structure" section. Surface the `references/palette.md` inventory (color tokens, type stacks, layout primitives, components, tabs/sortables/live-binds) inline in SKILL.md so it reads as the primary product, not an appendix (#636). ## [0.1.0] - 2026-05-09 ### Other - Add composing-html skill: progressive-disclosure HTML artifact composer (#634) -
README.md 5.1 KB
# composing-html Compose single-file HTML artifacts without hand-writing page chrome. The composer supplies `<!DOCTYPE>`, `<head>`, inlined CSS, `base.js`, design tokens, masthead, and colophon. You supply the body. The artifact is one file, no CDN, no build step, no server — the agent hands it over and it just opens. ## Inspiration Thariq Shihipar's [*The Unreasonable Effectiveness of HTML*](https://claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html) on the Anthropic blog argues HTML is the better artifact format than markdown for agent output: it carries tables, CSS, SVG, embedded code, live interactions, spatial layout, and images, and a single file is shareable and interactive in ways a markdown plan isn't. People will open and read an HTML file they wouldn't read as a hundred-line markdown plan. The worry near the end of that piece is real, though: ask an agent for HTML and you tend to get either tasteful but generic Claude-aesthetic boilerplate, or an over-engineered SPA. This skill is the narrow defense — a fixed, opinionated chrome (typography, color tokens, layout primitives, base interactions) so the agent spends its output budget on the *content*, not on re-deriving what a card or a badge looks like for the hundredth time. For the longer rationale, see *[The unreasonable effectiveness of HTML, and the skill that tries not to ruin it](https://muninn.austegard.com/blog/the-unreasonable-effectiveness-of-html-and-the-skill-that-tries-not-to-ruin-it.html)* on muninn.austegard.com. See [`SKILL.md`](SKILL.md) for the full inventory and workflow. See [`CHANGELOG.md`](CHANGELOG.md) for version history. ## Two workflows ### freeform — the default Most artifacts. One slot — `body_html` — for the page body. Use `--set body_html=@body.html` so multi-line HTML, quotes, and `&`/`<` characters don't fight JSON-string escaping: ```sh python scripts/build.py build freeform \ --set title='My Page' \ --set body_html=@body.html \ --out artifact.html ``` `--set KEY=VALUE` assigns a literal string; `--set KEY=@FILE` loads file contents verbatim. Repeatable across all spec fields. ### templates — when the shape repeats Twenty-one templates for recurring artifact shapes: PR reviews, status reports, incident postmortems, slide decks, design systems, flowcharts, kanban boards, prompt tuners, and more. Each has typed slots the template reasons over (`pr_review.findings[].severity`, `slide_deck.slides[].kind`). ```sh python scripts/build.py list # all templates python scripts/build.py describe pr_review # required keys + JSON skeleton python scripts/build.py build pr_review --spec spec.json --out review.html ``` Reach for a template only when the same artifact shape recurs across artifacts (same status report week after week, PR reviews across many PRs). For one-offs, `freeform` is less friction. ## What you get for free | Layer | What it covers | |---|---| | **Tokens** | Color (`--clay`, `--slate`, `--ivory`, `--ok/warn/err/info`, gray ramp), typography (`--serif`, `--sans`, `--mono`), geometry (radii, borders, shadows) | | **Layout** | `.page` / `.page--wide` / `.page--narrow`, `.grid--2|3|4`, `.stack`, `.row`, `.card`, `.rule` | | **Components** | `.eyebrow`, `.badge--{ok,warn,err,info,clay}`, `.kbd`, `.bullets`, styled `<code>` / `<pre><code>` / `<details>` | | **Interactions** | Tabs, drag-to-reorder (with cross-zone drops), live parameter bindings (`--bind-*` CSS variables driven by `<input>` + `data-bind`), auto copy buttons on every code block | | **Chrome** | DOCTYPE, head, masthead (`eyebrow` + `<h1>` + subtitle), inlined CSS + JS, colophon | Everything is inlined into the output file. Nothing fetches at view time. ## When to reach for it - "Compare options side-by-side" → `exploration.comparison_grid` - HTML version of a report or review → `report.status_report` or `review.pr_review` - "Make me a deck" → `deck.slide_deck` (arrow-key + space navigation) - Flowchart, module map, design system reference → `diagram.*`, `review.module_map`, `design.design_system` - Prototype with live-tunable parameters → `prototype.animation_sandbox` - "An HTML artifact" / "a single self-contained HTML file" → `freeform` ## When to skip Ad-hoc HTML snippets that don't need page chrome — forms emailed inline, widgets embedded in someone else's page, three-line examples in a chat reply. The skill is the chrome; if you don't need the chrome, you don't need the skill. ## Pitfall to avoid Inlining multi-line HTML into a JSON heredoc: ```sh # ❌ This produces invalid JSON — strings can't contain raw newlines or unescaped quotes cat > spec.json <<EOF { "body_html": "<section> <h2>Multi-line</h2> </section>" } EOF ``` Use `--set body_html=@body.html` instead, or assemble the spec in Python with `json.dump(spec, f)` so escaping is automatic. ## Tests ```sh python tests/test_smoke.py # no pytest required python -m pytest tests -q # with pytest ``` Covers every template with a representative spec plus explicit security regressions (HTML escaping, script-tag breakout, attribute injection, CSS-color injection, spec mutation) and the `--set` CLI paths. -
SKILL.md 12.5 KB
--- name: composing-html description: Composes single-file HTML artifacts (PR review writeups, status reports, incident postmortems, slide decks, design systems, prototypes, flowcharts, module maps, feature explainers, kanban boards, prompt tuners) from a small JSON spec instead of hand-written HTML/CSS/JS. Use when the user asks to "compare options side-by-side", requests an HTML version of a report or review or deck, asks for a flowchart, status update, postmortem, design system reference, interactive prototype, custom editor — or explicitly says "HTML artifact", "single HTML file", "self-contained HTML". Skip for ad-hoc HTML snippets (forms, emails, embedded widgets) where there's no template fit. metadata: version: 0.5.0 --- # composing-html Produce single-file HTML artifacts without hand-writing the page chrome. The composer supplies `<!DOCTYPE>`, `<head>`, inlined CSS, `base.js`, design tokens, masthead, and colophon. You supply a title and the body content. The product is the **chrome and inventory below** — primitives you can drop into any artifact without re-deriving what a card, badge, or eyebrow looks like. Templates are shortcuts on top of this, useful when the same artifact shape repeats; see [Templates](#templates-shortcuts-for-repeat-structure) near the end. ## Default workflow: freeform `freeform` gives you the whole chrome with one content slot — `body_html` — for the page body. Reach for it first. Reach for a template only when the structure repeats across artifacts (see [Templates](#templates-shortcuts-for-repeat-structure) near the end). There are two ways to invoke it. **Use the `--set` flow for anything with a substantial body** — it sidesteps the JSON-string escaping that bites heredoc-style spec writing (newlines, quotes, `<`/`&` inside multi-line HTML). ### Recommended: HTML in a file, metadata via `--set` ``` 1. Write the body to a .html file directly (no JSON, no escaping). 2. python scripts/build.py build freeform \ --set title='My Page' \ --set subtitle='Optional subhead' \ --set body_html=@body.html \ --out artifact.html ``` `--set KEY=VALUE` assigns a literal string; `--set KEY=@FILE` loads the file contents verbatim into that spec field. Repeat for any field. Works for `body_html`, `extra_css`, `extra_js`, `eyebrow`, `page_class`, and the same `*_html` fields in any other template (`summary_html`, `intro_html`, `details_html`, …). ### Spec-file workflow (best for structured templates) ``` 1. python scripts/build.py describe <template> # required keys + skeleton 2. write spec.json 3. python scripts/build.py build <template> --spec spec.json --out artifact.html ``` For templates with typed slots (`pr_review.findings[]`, `slide_deck.slides[]`, `status_report.metrics[]`), the spec file is the right shape — the template reasons over the structure. For `freeform`, the spec is mostly a thin config wrapper around one HTML string; the `--set` flow above is usually less friction. You can mix both: small `spec.json` for metadata, `--set body_html=@body.html` for the heavy bit. `--set` overrides any matching field from `--spec`. ### Pitfall: don't inline multi-line HTML into a JSON heredoc `cat > spec.json <<EOF { "body_html": "<multi\nline>\n..." } EOF` does not produce valid JSON — JSON strings can't contain raw newlines or unescaped quotes. Either: - use `--set body_html=@body.html` (recommended), or - assemble the spec in Python with `json.dump(spec, f)` so escaping is automatic. ## Inventory Everything in this section is loaded into every artifact via inlined CSS and `base.js`. Use these tokens and classes inside `body_html` (or any template's `*_html` field) without re-declaring them. ### Color tokens | Token | Hex | Use | |---|---|---| | `--ivory` | `#FAF9F5` | Page background | | `--paper` | `#FFFFFF` | Card background | | `--slate` | `#141413` | Headings, inverted background | | `--clay` | `#D97757` | Brand accent (lines, primary actions) | | `--clay-d` | `#B85C3E` | Hover/dark variant | | `--oat` | `#E3DACC` | Soft contrast surface | | `--olive` | `#788C5D` | Success, secondary accent | | `--rust` | `#B04A3F` | Errors, destructive | | `--moss` | `#4A6B3A` | Success text | | `--g100` … `--g700` | grays | Surfaces, borders, body text | Semantic aliases: `--ok`, `--warn`, `--err`, `--info`. ### Type stacks - `--serif` — display headings (h1, h2, big numerics). - `--sans` — body text (default). - `--mono` — code, eyebrows, badges, captions. ### Geometry `--radius-sm` (6px) · `--radius` (10px) · `--radius-lg` (16px) · `--border` · `--border-soft` · `--shadow-card` · `--shadow-pop`. ### Layout primitives - `.page` — main column (1080px max). Variants: `.page--wide` (1280px), `.page--narrow` (720px). Set via the `page_class` spec key. - `.masthead` — header strip with `.eyebrow` + `<h1>` + `.subtitle` (auto-rendered from `title`/`subtitle`/`eyebrow` unless `show_masthead` is false). - `.grid .grid--2|3|4|auto` — responsive CSS grid. - `.stack`, `.row` — vertical / horizontal flex. - `.card`, `.card--soft`, `.card--elev` — content containers. - `.rule` — `<hr>` underline below `<h2>`. - `.colophon` — optional footer strip; pass `colophon="text"` to `page()` to show it (off by default). ### Components - **Eyebrow**: `<div class="eyebrow">SECTION</div>` — small all-caps label with a leading clay rule. - **Badge**: `<span class="badge badge--ok|warn|err|info|clay">v1.0</span>`. - **Kbd**: `<span class="kbd">⌘K</span>`. - **Bullets**: `<ul class="bullets"><li>…</li></ul>` — clay dots. - **Code**: inline `<code>` and block `<pre><code>`. Block code gets a `copy` button automatically via `base.js`. - **Details**: native `<details><summary>…</summary>…</details>` styled. ### Tabs ```html <div class="tabgroup"> <div class="tabs"> <button data-target="a">Tab A</button> <button data-target="b">Tab B</button> </div> <div class="tab-panel" data-id="a">…</div> <div class="tab-panel" data-id="b">…</div> </div> ``` `base.js` wires this automatically and selects the first tab by default. ### Drag-to-reorder ```html <div data-sortable="true"> <div draggable="true">…</div> <div draggable="true">…</div> </div> ``` Optional cross-zone drops: add `data-zone="<id>"` to each container. ### Live parameter bindings ```html <input type="range" data-bind="size" min="0" max="100" value="50" data-format="number" data-unit="px"> <span data-out="size"></span> <style>.box { width: var(--bind-size, 50px); }</style> ``` The CSS custom property `--bind-<name>` is updated on every input event, and any `[data-out="<name>"]` element receives the formatted value. ## Output rules Spend output tokens on **content**, not chrome: 1. **Never write `<html>`, `<head>`, `<style>`, `<script>`, or `<link>`.** The composer adds all of them. If you find yourself writing a complete page, you missed the skill. <!-- rule:chrome-leak --> 2. **Don't restate design tokens.** Reuse the inventory above — `var(--clay)`, `.card`, `.badge--warn`, `.bullets`, etc. are already loaded. Don't hardcode hex/`rgb()` colours, inline `font-family`/`font-size`, or reference tokens that aren't in the palette. <!-- rule:hardcoded-color rule:inline-typography rule:undefined-token --> 3. **`body_html` is HTML, not a JSON dialect.** Write `<section>`, `<h2>`, `<ul class="bullets">` directly. No translation layer. 4. **Anything in an `_html` field is inserted verbatim** — escape any user-supplied content yourself. All other string values are HTML-escaped automatically. 5. **One artifact per build.** Browser tabs are free. ## Checking output After building, lint the artifact before presenting it: ``` python scripts/build.py check artifact.html ``` The checker is deterministic — no model call, stdlib only. It doesn't grade taste (the fixed chrome already prevents the usual AI tells); it flags content that breaks *out* of the design system or wires `base.js` hooks to nothing — the failure modes the chrome can't prevent on its own: | rule | catches | severity | |---|---|---| | `chrome-leak` | `<html>/<head>/<link>` (and top-level `<style>/<script>`) in body_html | error | | `undefined-token` | `var(--typo)` — a token not in the palette or declared here | error | | `broken-tabs` | `data-target` with no matching `.tab-panel[data-id]` | error | | `hardcoded-color` | `#hex` / `rgb()` literals instead of palette tokens | warn | | `inline-typography` | `font-family` / `font-size` overriding the type stacks | warn | | `undefined-token` for `--bind-*` | (allowed — created by `data-bind`) | — | | `nested-card` | `.card` inside `.card` | warn | | `broken-bind` | `data-bind` with no consumer, or orphan `data-out` | warn | | `broken-sortable` | `data-sortable` with no `draggable` children | warn | | `heading-skip` | heading levels that jump (h1 → h3) | warn | | `img-no-alt` | `<img>` without an `alt` attribute | warn | Exit code is non-zero when any error-severity rule fires. The output rules above carry `<!-- rule:ID -->` anchors tying each guidance line to its check, so the teaching and the enforcement stay in sync. Full-artifact vs body fragment is auto-detected; force with `--full` / `--fragment`. `--json` emits machine-readable findings. Contrast ratios are intentionally not checked — the token pairs are pre-vetted and regex can't judge author-introduced pairs without false positives. ## Iteration Edit the spec, re-run `build`, open in a browser. If a layout pattern repeats across multiple artifacts, that's when a template earns its keep — otherwise stay in `freeform`. ## Templates: shortcuts for repeat structure When the **same artifact shape** recurs (status reports week after week, PR reviews across many PRs, slide decks with consistent navigation), a template's fixed slot map is worth the translation cost. It enforces cross-artifact consistency and skips the layout decisions you'd otherwise re-derive each time. Use a template only when: 1. You're producing the same artifact shape repeatedly. 2. The repeat structure justifies a fixed slot map. 3. Cross-artifact consistency matters more than per-artifact flexibility. Otherwise: `freeform`. ``` 1. python scripts/build.py list # all templates, one-line summaries 2. python scripts/build.py describe <template> # required keys + JSON skeleton 3. write spec.json # only your content + parameters 4. python scripts/build.py build <template> --spec spec.json --out artifact.html ``` `describe` prints a valid-JSON starter skeleton you can edit in place. For worked examples, see `references/templates.md` — but only after picking a template; reading it cold wastes context. For templates with prose-heavy `*_html` slots (e.g. `summary_html`, `intro_html`, `details_html`), the same `--set KEY=@FILE` mechanism from the freeform workflow applies — load the prose from a `.html` file rather than escaping it into the JSON spec. There are 21 templates, grouped into 9 categories plus `freeform`: - `report.*` — status_report, incident_report - `review.*` — pr_review, code_walkthrough, module_map - `editor.*` — triage_board, flag_editor, prompt_tuner - `deck.*` — slide_deck (arrow-key + space navigation) - `design.*` — design_system, component_variants - `exploration.*` — comparison_grid, design_directions, implementation_plan - `research.*` — feature_explainer, concept_explainer - `diagram.*` — svg_figure_sheet, flowchart - `prototype.*` — animation_sandbox, click_flow Some templates with prose-heavy slots take raw HTML in keys ending with `_html` (e.g. `summary_html`, `intro_html`, `details_html`). Same rules as `freeform.body_html`: use the inventory above, escape user-supplied content. ## Tests `tests/test_smoke.py` covers every template with a representative spec plus explicit security regressions (table escaping, script-tag breakout in `prompt_tuner`, attribute injection in `flag_editor`, CSS-color injection, spec mutation in `module_map`). `tests/test_checker.py` covers the `check` linter — one assertion per rule (fires on the violation, silent on the clean case). Run with: ``` python composing-html/tests/test_smoke.py # no pytest required python composing-html/tests/test_checker.py # no pytest required python -m pytest composing-html/tests -q # if pytest is available ``` When adding or changing a template, add a spec entry and any regression asserts before merging. When adding a checker rule, add it to both `scripts/checker.py` and a `<!-- rule:ID -->` anchor in the relevant guidance line, plus a test assertion.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.