inkSpec — one engine, any design system
The problem class
A rendered artifact should not hard-code its design system.
inkSpec compiles a markdown / YAML / JSON spec — LLM-authorable — into a single self-contained HTML file: a config-intake form or a read-only dashboard, no build step, no dependencies, working offline from file://. The design system it renders through is an interchangeable pack, not a baked-in palette: a third party's whole visual vocabulary plugs in as one CSS file of tokens, and everything downstream renders through it identically.
What it is
One engine renders both spec kinds. engine.js is a single classic script — a dependency-free YAML-subset parser, the spec envelope, the form and view renderers — that runs inlined in the browser and, unchanged, under node:vm for tests. compile-spec.mjs inlines the selected pack, its adapters, the engine, and the spec (plus optional data) into one HTML file. A spec can also be served to an MCP host as a ui:// resource. Day/night mode follows the OS and fails to light.
When you'd reach for it
Agent ↔ human surfaces
An LLM authors a spec mid-conversation and the host renders it as a real UI: config intake, triage checklists, approval gates that surface exactly what an agent is about to do and collect an explicit go / no-go, dashboards over JSON an agent produced. Inside an MCP host, Submit returns the assembled answers to the agent as structured data — no copy-paste, no re-parsing.
Files you can hand to someone
Air-gapped, regulated, and field environments where a form or dashboard must run with no network and no installed toolchain — and hand-off intake: send one file, the recipient opens it from file://, fills it, and returns the exported .env / JSON.
USECASES.md says so. inkSpec is for the cases where one of those two properties decides the tool.Two of the shipped artifacts
One engine, both kinds: a dashboard and a survey.
Both frames are the repo's real compiled artifacts, embedded live — click either to open it full-page. Left, the view kind: agentic-eval-harness scorecards as a 69-line spec over the harness's own recorded data. Right, the form kind: a feedback/research intake with choice fields, a long-answer textarea, and a conditional follow-up. The dashboard's full spec sits at the bottom of the tab.
eval-scoring adapter does all computation — the spec never does. In the survey, tick open to follow-up and an email field appears (x-forge-when — declarative conditional visibility, no code), validation objects to a missing required answer, and the export bar writes .env / JSON. Both artifacts run offline and follow the OS day/night preference.compile-spec.mjs specs/eval.view.yaml --data data/eval-sample.json -o eval-dashboard.html the exact recipe that builds the repo's shipped copy; the data bundle is the harness's own output — recorded claude-opus-4-8 runs, live + replay, three domains compile-spec.mjs specs/survey.form.yaml -o survey.html the spec kind swaps, nothing else does — same engine, same compiler, no data bundle needed The entire authored input
# eval.view.yaml — agentic-eval-harness eval-dashboard, re-expressed as a
# inkSpec VIEW spec. Compiles (with the harness data bundle) to a
# self-contained eval-dashboard.html that renders identically to the original.
kind: view
x-forge-name: eval-dashboard
title: "agentic-eval-harness"
adapters: [eval-scoring]
footer: "Renders <a href=\"https://github.com/chris-youngblut-solutions/agentic-eval-harness\">agentic-eval-harness</a> JSON (scorecards + transcripts). The harness is the source of truth for all metrics and scoring."
views:
- key: overview
label: "Overview"
select: {domain: true, run: after, case: false}
widgets:
- widget: heading
value: "$domain"
- widget: chips
source: card
fields: [run_id, backend, model]
- widget: stat-cards
cards:
- value: "eval-scoring.passedCount(card)"
denom: "{card.cases.length}"
label: "cases passed"
- value: "eval-scoring.totalScore(card)"
denom: "{card.cases.length}"
label: "total score"
- widget: hard-gate-banner
source: "eval-scoring.hardGateFailures(card)"
- widget: metric-rollup
source: "eval-scoring.byMetric(card)"
- widget: case-table
source: card
- key: diff
label: "Regression diff"
select: {domain: true, run: both, case: false}
widgets:
- widget: heading
value: "Regression diff"
- widget: regression-diff
before: before
after: after
- key: transcript
label: "Transcript"
select: {domain: true, run: after, case: true}
widgets:
- widget: heading
value: "Transcript"
- widget: transcript
source: transcript
- key: cross
label: "Cross-domain"
select: {domain: false, run: false, case: false}
widgets:
- widget: heading
value: "Cross-domain"
- widget: caption
text: "Latest run per domain — one engine, N domains."
- widget: cross-grid
- key: trend
label: "Trend"
select: {domain: true, run: false, case: false}
widgets:
- widget: heading
value: "$domain"
- widget: caption
text: "Total score across this domain's runs — oldest to newest."
- widget: trend
source: "eval-scoring.scoreTrend(dataset, domain)"
caption: "score over runs" The ingest engine
A foreign design system enters as one CSS file of tokens.
The engine, the widget layer, and the chrome consume only the inkspec-pack/v1 token names — never a literal colour. Anything that satisfies the contract re-skins the whole artifact with no engine edit; anything that doesn't is refused.
packs/<name>/ — one CSS file of custom properties 51 required tokens (surfaces, inks, fills, on-fill pairs, type, space, radius, motion) plus 18 optional identity tokens, all-or-none per family packs/contract.json — inkspec-pack/v1 the vocabulary the compiler enforces; a pack missing core tokens does not compile inkspec-widgets.css — declares no colours of its own every value is a token; charts are CSS-first, so they re-skin with the pack too adapters/<vocab>.css — aliases a legacy vocabulary onto the contract `cabin.css` bridges the older `--cabin-*` names via `var()`, so aliased artifacts track the pack and its day/night mode compile-spec.mjs --pack <name | dir | file> inlines pack → adapters → engine into one self-contained file The default pack is Collins, drawn from a 1972 Collins Radio 8837A terminal — the reference implementation of the contract. Its predecessor vocabulary, Cabin, was not deleted: it became adapter #1, which is the migration story the architecture is built to tell.
Proof by test, not screenshot
A test suite asserts the swap; nobody has to eyeball it.
tests/pack-swap.test.mjs compiles the same spec through different packs and asserts on the compiled output — the interchangeability claim is executable.
Values change, names hold
A synthetic conforming pack — every core token, day and night, deliberately distinct values — swaps in by directory. Every token value observably changes; the token vocabulary stays fixed. That is the contract doing its job.
Default and explicit are identical
Compiling with no pack argument and compiling with --pack collins produce identical output — the default is a real pack going through the same path, not a special case.
Legacy artifacts ride along
The cabin adapter is inlined as var() aliases, not copied values — so a spec written against the old vocabulary resolves through whatever pack is loaded, modes included.
Non-conforming packs are rejected
A pack that would break the offline guarantee is refused at compile time, and the CSP/offline guards are re-asserted for every pack that gets through. Ingesting a foreign design system never costs the single-file property.
How it converged
Two tools became one engine; the palette became a pack.
inkSpec generalizes two earlier single-file tools — a config-intake form generator and an eval dashboard — into one renderer. Once the engine was shared, the design system was the last thing hard-coded in it; the pack seam cut it loose.
The seed commit reads feat(themes): make the design system an interchangeable theme pack; the pack + adapter architecture, contract enforcement, and the rename from spec-renderer to inkSpec followed in the same arc. The repo keeps its original spec-renderer slug.
Status
Public and working: the engine, the compiler, the Collins pack, the cabin adapter, and the 386-test suite, plus shipped examples — a web-app .env form, a survey, a settings panel, an eval dashboard, and render.html, a blank renderer you drop a spec into. Pack authoring is documented in packs/PACKS.md; the upstream SDK that authors and validates packs is a separate, private tool.