writing / inkspec 8 public repos
← writing
platformsagent-infra spec-renderer ↗

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.

1
CSS file is a whole pack
51
required tokens in the contract
0
build steps or runtime dependencies
386
tests — no browser, no network

When you'd reach for it

MCP

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.

file://

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.

And when you wouldn't. If neither offline portability nor an MCP surface is load-bearing, a hosted form builder is less work — the repo's own 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-dashboard (view) — click to open · the spec
product-survey (form) — click to open · the spec
Open them full-page to interact. In the dashboard, every widget is the engine's, every number is the harness's, and the 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.
eval-dashboard
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
product-survey
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"
eval.view.yaml — the whole authored surface. The data bundle is the harness's JSON, untouched.

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.

The pack
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
The contract
packs/contract.jsoninkspec-pack/v1 the vocabulary the compiler enforces; a pack missing core tokens does not compile
The widget layer
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
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
The compiler
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.

A pack carries more than colours. Collins declares which backgrounds may carry text, which fills pair with which inks, and which tokens are deliberately below AA and where they are legal — accessibility facts a CSS parser cannot infer, made machine-checkable because the pack states them. The authoritative machine-readable contract lives in an upstream pack SDK; the repo pins the slice it enforces.

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.

01

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.

02

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.

03

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.

04

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.