diff --git a/Makefile b/Makefile index 441c30f..bdffafb 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,7 @@ versions: contracts: node scripts/review-contracts.mjs + node scripts/check-contracts.mjs # Every page under examples/ is a real artifact and faces the full gate, incl. # the >=3 HTML-native feature floor. Only examples/primitives/*.html — the diff --git a/contracts/primitives.json b/contracts/primitives.json new file mode 100644 index 0000000..7a9e58f --- /dev/null +++ b/contracts/primitives.json @@ -0,0 +1,104 @@ +{ + "primitives": [ + { + "id": "donut", + "pickWhen": "2–5 mutually-exclusive categories where proportion-at-a-glance beats absolute counts", + "register": "instrument", + "requiredFeatures": ["inline_svg"], + "example": "examples/primitives/01-donut.html", + "href": "primitives/01-donut.html", + "reference": "references/primitives/donut.md", + "skillHeading": "#### Donut\\b" + }, + { + "id": "bar", + "pickWhen": "≤12 items where order matters and a single magnitude per item is the signal", + "register": "instrument", + "requiredFeatures": [], + "example": "examples/primitives/02-bar.html", + "href": "primitives/02-bar.html", + "reference": "references/primitives/bar.md", + "skillHeading": "#### Bar\\b" + }, + { + "id": "sparkline", + "pickWhen": "2–4 hero metrics where each metric is the subject; current value + recent-trend shape is the one-glance read", + "register": "instrument", + "requiredFeatures": ["inline_svg"], + "example": "examples/primitives/03-sparkline.html", + "href": "primitives/03-sparkline.html", + "reference": "references/primitives/sparkline.md", + "skillHeading": "#### Sparkline\\b" + }, + { + "id": "stacked-bar", + "pickWhen": "categories sum to a meaningful whole each period; 7–30 periods", + "register": "instrument", + "requiredFeatures": ["cross_highlight"], + "example": "examples/primitives/04-stacked-bar.html", + "href": "primitives/04-stacked-bar.html", + "reference": "references/primitives/stacked-bar.md", + "skillHeading": "#### Stacked bar\\b" + }, + { + "id": "topology", + "pickWhen": "connections are the point and node count is ≤30", + "register": "instrument", + "requiredFeatures": ["inline_svg"], + "example": "examples/primitives/05-topology.html", + "href": "primitives/05-topology.html", + "reference": "references/primitives/topology.md", + "skillHeading": "#### Topology\\b" + }, + { + "id": "dense-ops-table", + "pickWhen": "flat list of records keyed by an identifier; ≥6 rows; filter/sort is the central interaction", + "register": "instrument", + "requiredFeatures": ["table"], + "example": "examples/primitives/06-table-ops.html", + "href": "primitives/06-table-ops.html", + "reference": "references/primitives/dense-ops-table.md", + "skillHeading": "#### Dense ops table\\b" + }, + { + "id": "comparison-matrix", + "pickWhen": "X vs Y vs Z decision matrix with shared criteria and live weight tuning", + "register": "instrument", + "requiredFeatures": ["table"], + "example": "examples/primitives/07-table-comparison.html", + "href": "primitives/07-table-comparison.html", + "reference": "references/primitives/comparison-matrix.md", + "skillHeading": "#### Comparison matrix\\b" + }, + { + "id": "annotated-diff", + "pickWhen": "code changes or line-anchored critique where adjacency to the source beats a side panel", + "register": "instrument", + "requiredFeatures": ["copy_as_prompt"], + "example": "examples/primitives/08-diff.html", + "href": "primitives/08-diff.html", + "reference": "references/primitives/annotated-diff.md", + "skillHeading": "#### Annotated diff\\b" + }, + { + "id": "log-stream", + "pickWhen": "a tail of timestamped events with a categorical level; newest-first reading", + "register": "instrument", + "requiredFeatures": ["filtering"], + "example": "examples/primitives/09-logs.html", + "href": "primitives/09-logs.html", + "reference": "references/primitives/log-stream.md", + "skillHeading": "#### Log stream\\b" + }, + { + "id": "scatter", + "pickWhen": "two genuinely independent, well-spread dimensions across ≥10 points where the relationship is the signal", + "register": "instrument", + "requiredFeatures": ["inline_svg"], + "example": "examples/primitives/10-scatter.html", + "href": "primitives/10-scatter.html", + "reference": "references/primitives/scatter.md", + "skillHeading": "#### Scatter\\b" + } + ] +} diff --git a/contracts/shapes.json b/contracts/shapes.json new file mode 100644 index 0000000..0f1f11b --- /dev/null +++ b/contracts/shapes.json @@ -0,0 +1,112 @@ +{ + "shapes": [ + { + "id": "dashboard", + "pickWhen": "Network scans, system reports, device lists, ops data — anything tabular with categories that benefit from filtering", + "register": "instrument", + "requiredFeatures": ["search", "table", "copy_as_prompt"], + "example": "examples/dashboard.html", + "href": "dashboard.html", + "reference": "references/shapes/dashboard.md" + }, + { + "id": "document", + "pickWhen": "Plans, specs, briefings, essays, brainstorms, meeting notes — prose-heavy where you'd read paragraph-to-paragraph", + "register": "reading", + "requiredFeatures": ["table", "copy_as_prompt"], + "example": "examples/document.html", + "href": "document.html", + "reference": "references/shapes/document.md" + }, + { + "id": "timeline", + "pickWhen": "Dated logs, diaries, retrospectives, project histories, trip journals", + "register": "reading", + "requiredFeatures": ["filtering"], + "example": "examples/timeline.html", + "href": "timeline.html", + "reference": "references/shapes/timeline.md" + }, + { + "id": "runbook", + "pickWhen": "Disaster recovery, deploy guides, machine rebuilds — sequential procedure being executed not read", + "register": "instrument", + "requiredFeatures": ["copy_as_prompt", "toggle"], + "example": "examples/runbook.html", + "href": "runbook.html", + "reference": "references/shapes/runbook.md" + }, + { + "id": "comparison", + "pickWhen": "X vs Y vs Z decision matrices, model comparisons, vendor pickers", + "register": "instrument", + "requiredFeatures": ["table", "copy_as_prompt"], + "example": "examples/comparison.html", + "href": "comparison.html", + "reference": "references/shapes/comparison.md" + }, + { + "id": "network-map", + "pickWhen": "People/relationships, brain backlinks, dependencies — connections matter", + "register": "instrument", + "requiredFeatures": ["search", "inline_svg"], + "example": "examples/network-map.html", + "href": "network-map.html", + "reference": "references/shapes/network-map.md" + }, + { + "id": "triage-board", + "pickWhen": "Bucketing items into 3–5 columns (Now/Next/Later/Cut), inbox triage, GTD reorg", + "register": "instrument", + "requiredFeatures": ["drag", "copy_as_prompt"], + "example": "examples/triage-board.html", + "href": "triage-board.html", + "reference": "references/shapes/triage-board.md" + }, + { + "id": "developer", + "pickWhen": "PR writeups, code review, explain this code — annotated diff with severity findings", + "register": "instrument", + "requiredFeatures": ["table", "textarea"], + "example": "examples/developer.html", + "href": "developer.html", + "reference": "references/shapes/developer.md" + }, + { + "id": "editorial", + "pickWhen": "Argument-driven long-form where the reader absorbs a sustained position", + "register": "reading", + "requiredFeatures": ["search", "copy_as_prompt"], + "example": "examples/editorial.html", + "href": "editorial.html", + "reference": "references/shapes/editorial.md" + }, + { + "id": "execution-log", + "pickWhen": "Live or snapshot execution telemetry — branch status, plan progress, event stream, observed state", + "register": "instrument", + "requiredFeatures": ["search", "table"], + "example": "examples/execution-log.html", + "href": "execution-log.html", + "reference": "references/shapes/execution-log.md" + }, + { + "id": "deck-review", + "pickWhen": "Slide deck review/approval surfaces — status memo, slide preview, per-slide notes, send-back workflow", + "register": "hybrid", + "requiredFeatures": ["local_storage", "textarea"], + "example": "examples/deck-review.html", + "href": "deck-review.html", + "reference": "references/shapes/deck-review.md" + }, + { + "id": "podcast", + "pickWhen": "A podcast episode rendered as a briefing alongside a transcript browser", + "register": "hybrid", + "requiredFeatures": ["local_storage", "toggle"], + "example": "examples/podcast.html", + "href": "podcast.html", + "reference": "references/shapes/podcast.md" + } + ] +} diff --git a/examples/podcast.html b/examples/podcast.html index af66f70..bb34e5a 100644 --- a/examples/podcast.html +++ b/examples/podcast.html @@ -593,7 +593,7 @@

The instrument you keep

render-as-html↗
-
The Claude Code / Codex skill this gallery documents. Nine page shapes, one palette, canonical primitives, copy-as-prompt as the load-bearing pattern. (Real.)
+
The Claude Code / Codex skill this gallery documents. Twelve page shapes, one palette, canonical primitives, copy-as-prompt as the load-bearing pattern. (Real.)
@@ -611,8 +611,8 @@

The instrument you keep

Read next

diff --git a/scripts/check-contracts.mjs b/scripts/check-contracts.mjs new file mode 100644 index 0000000..686a7fe --- /dev/null +++ b/scripts/check-contracts.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const FEATURE_KEYS = new Set([ + "search", + "inline_svg", + "table", + "copy_as_prompt", + "sorting", + "filtering", + "local_storage", + "drag", + "toggle", + "textarea", + "cross_highlight", +]); +const NOT_SHAPES = new Set(["checklist", "podcast-transcript"]); +const STALE_COUNTS = [ + "Nine page shapes", + "nine chart and table primitives", + "All eleven shape examples", +]; + +const argv = process.argv.slice(2); +let root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootIdx = argv.indexOf("--root"); +if (rootIdx >= 0) { + if (!argv[rootIdx + 1]) { + console.error("check-contracts: --root needs a directory"); + process.exit(2); + } + root = path.resolve(argv[rootIdx + 1]); +} + +const read = (rel) => fs.readFileSync(path.join(root, rel), "utf8"); +const exists = (rel) => fs.existsSync(path.join(root, rel)); + +const fails = []; +const note = (msg) => fails.push(msg); + +let shapesDoc; +let primitivesDoc; +try { + shapesDoc = JSON.parse(read("contracts/shapes.json")); + primitivesDoc = JSON.parse(read("contracts/primitives.json")); +} catch (err) { + console.error(`check-contracts: cannot read registry JSON (${err.message})`); + process.exit(2); +} + +const shapes = shapesDoc.shapes; +const primitives = primitivesDoc.primitives; +if (!Array.isArray(shapes) || !Array.isArray(primitives)) { + console.error("check-contracts: registry JSON must have shapes[] and primitives[]"); + process.exit(2); +} + +console.log(`${shapes.length} shapes · ${primitives.length} primitives`); + +if (shapes.length !== 12) note(`expected 12 shapes, found ${shapes.length}`); +if (primitives.length !== 10) note(`expected 10 primitives, found ${primitives.length}`); + +const shapeIds = new Set(); +for (const shape of shapes) { + if (!shape.id) { + note("a shapes.json entry is missing id"); + continue; + } + if (NOT_SHAPES.has(shape.id)) note(`${shape.id} must stay out of the shape list`); + if (shapeIds.has(shape.id)) note(`duplicate shape id ${shape.id}`); + shapeIds.add(shape.id); + if (!shape.pickWhen) note(`${shape.id}: missing pickWhen`); + if (!shape.register) note(`${shape.id}: missing register`); + if (!Array.isArray(shape.requiredFeatures)) note(`${shape.id}: missing requiredFeatures`); + else { + for (const key of shape.requiredFeatures) { + if (!FEATURE_KEYS.has(key)) note(`${shape.id}: unknown required feature ${key}`); + } + } + if (!shape.example) note(`${shape.id}: missing example`); + else if (!exists(shape.example)) note(`${shape.id}: missing ${shape.example}`); + if (!shape.reference) note(`${shape.id}: missing reference`); + else if (!exists(shape.reference)) note(`${shape.id}: missing ${shape.reference}`); +} + +const primitiveIds = new Set(); +for (const primitive of primitives) { + if (!primitive.id) { + note("a primitives.json entry is missing id"); + continue; + } + if (primitiveIds.has(primitive.id)) note(`duplicate primitive id ${primitive.id}`); + primitiveIds.add(primitive.id); + if (!primitive.pickWhen) note(`${primitive.id}: missing pickWhen`); + if (!primitive.register) note(`${primitive.id}: missing register`); + if (!Array.isArray(primitive.requiredFeatures)) note(`${primitive.id}: missing requiredFeatures`); + else { + for (const key of primitive.requiredFeatures) { + if (!FEATURE_KEYS.has(key)) note(`${primitive.id}: unknown required feature ${key}`); + } + } + if (!primitive.example) note(`${primitive.id}: missing example`); + else if (!exists(primitive.example)) note(`${primitive.id}: missing ${primitive.example}`); + if (!primitive.reference) note(`${primitive.id}: missing reference`); + else if (!exists(primitive.reference)) note(`${primitive.id}: missing ${primitive.reference}`); +} + +let skill = ""; +let gallery = ""; +let primGallery = ""; +let harness = ""; +try { + skill = read("SKILL.md"); + gallery = read("examples/index.html"); + primGallery = read("examples/primitives.html"); + harness = read("scripts/perf_harness.py"); +} catch (err) { + note(`cannot read a compared file (${err.message})`); +} + +for (const shape of shapes) { + if (!shape.id) continue; + if (!skill.includes(shape.id)) note(`${shape.id}: SKILL.md does not name this id`); + if (shape.href && !gallery.includes(shape.href)) { + note(`${shape.id}: gallery missing link ${shape.href}`); + } + const heading = new RegExp(`#### \`${shape.id}\``); + if (skill && !heading.test(skill)) note(`${shape.id}: SKILL.md missing compact contract`); +} + +for (const primitive of primitives) { + if (!primitive.id) continue; + if (!skill.includes(primitive.id)) note(`${primitive.id}: SKILL.md does not name this id`); + if (primitive.href && !primGallery.includes(primitive.href)) { + note(`${primitive.id}: primitives gallery missing link ${primitive.href}`); + } + if (primitive.skillHeading) { + const heading = new RegExp(primitive.skillHeading, "i"); + if (skill && !heading.test(skill)) { + note(`${primitive.id}: SKILL.md missing contract ${primitive.skillHeading}`); + } + } +} + +if (harness) { + if (!harness.includes("contracts/shapes.json")) { + note("scripts/perf_harness.py does not load contracts/shapes.json"); + } + if (!harness.includes("contracts/primitives.json")) { + note("scripts/perf_harness.py does not load contracts/primitives.json"); + } + if (/^SHAPE_REFERENCES = \{/m.test(harness)) { + note("scripts/perf_harness.py still hardcodes SHAPE_REFERENCES"); + } + if (/^PRIMITIVE_FILES = \{/m.test(harness)) { + note("scripts/perf_harness.py still hardcodes PRIMITIVE_FILES"); + } +} + +if (exists("references/shapes")) { + for (const name of fs.readdirSync(path.join(root, "references/shapes"))) { + if (!name.endsWith(".md")) continue; + const id = name.slice(0, -3); + if (!shapeIds.has(id)) note(`references/shapes/${name} is not in contracts/shapes.json`); + } +} + +if (exists("examples/podcast.html")) { + const podcast = read("examples/podcast.html"); + for (const stale of STALE_COUNTS) { + if (podcast.includes(stale)) note(`examples/podcast.html still says ${stale}`); + } +} + +if (fails.length) { + console.error("check-contracts: drift detected"); + for (const line of fails) console.error(` ${line}`); + process.exit(1); +} + +console.log("check-contracts: 12 shapes and 10 primitives match the tree."); +process.exit(0); diff --git a/scripts/perf_harness.py b/scripts/perf_harness.py index 3e307fe..95e5ff3 100644 --- a/scripts/perf_harness.py +++ b/scripts/perf_harness.py @@ -67,73 +67,30 @@ "podcast-transcript.html", "checklist.html", ] -SHAPE_REFERENCES = { - "dashboard": {"file": "examples/dashboard.html", "href": "dashboard.html"}, - "document": {"file": "examples/document.html", "href": "document.html"}, - "editorial": {"file": "examples/editorial.html", "href": "editorial.html"}, - "timeline": {"file": "examples/timeline.html", "href": "timeline.html"}, - "runbook": {"file": "examples/runbook.html", "href": "runbook.html"}, - "comparison": {"file": "examples/comparison.html", "href": "comparison.html"}, - "network-map": {"file": "examples/network-map.html", "href": "network-map.html"}, - "triage-board": {"file": "examples/triage-board.html", "href": "triage-board.html"}, - "developer": {"file": "examples/developer.html", "href": "developer.html"}, - "execution-log": {"file": "examples/execution-log.html", "href": "execution-log.html"}, - "deck-review": {"file": "examples/deck-review.html", "href": "deck-review.html"}, - "podcast": {"file": "examples/podcast.html", "href": "podcast.html"}, -} +def _load_contract_json(rel: str) -> dict: + return json.loads((REPO / rel).read_text(encoding="utf-8")) -PRIMITIVE_FILES = { - "donut": { - "file": "examples/primitives/01-donut.html", - "href": "primitives/01-donut.html", - "contract": r"#### Donut\b", - }, - "bar": { - "file": "examples/primitives/02-bar.html", - "href": "primitives/02-bar.html", - "contract": r"#### Bar\b", - }, - "sparkline": { - "file": "examples/primitives/03-sparkline.html", - "href": "primitives/03-sparkline.html", - "contract": r"#### Sparkline\b", - }, - "stacked-bar": { - "file": "examples/primitives/04-stacked-bar.html", - "href": "primitives/04-stacked-bar.html", - "contract": r"#### Stacked bar\b", - }, - "topology": { - "file": "examples/primitives/05-topology.html", - "href": "primitives/05-topology.html", - "contract": r"#### Topology\b", - }, - "dense-ops-table": { - "file": "examples/primitives/06-table-ops.html", - "href": "primitives/06-table-ops.html", - "contract": r"#### Dense ops table\b", - }, - "comparison-matrix": { - "file": "examples/primitives/07-table-comparison.html", - "href": "primitives/07-table-comparison.html", - "contract": r"#### Comparison matrix\b", - }, - "annotated-diff": { - "file": "examples/primitives/08-diff.html", - "href": "primitives/08-diff.html", - "contract": r"#### Annotated diff\b", - }, - "log-stream": { - "file": "examples/primitives/09-logs.html", - "href": "primitives/09-logs.html", - "contract": r"#### Log stream\b", - }, - "scatter": { - "file": "examples/primitives/10-scatter.html", - "href": "primitives/10-scatter.html", - "contract": r"#### Scatter\b", - }, -} + +def _shape_references() -> dict: + return { + row["id"]: {"file": row["example"], "href": row["href"]} + for row in _load_contract_json("contracts/shapes.json")["shapes"] + } + + +def _primitive_files() -> dict: + return { + row["id"]: { + "file": row["example"], + "href": row["href"], + "contract": row["skillHeading"], + } + for row in _load_contract_json("contracts/primitives.json")["primitives"] + } + + +SHAPE_REFERENCES = _shape_references() +PRIMITIVE_FILES = _primitive_files() # Public episode metadata, synthetic body text. This exercises a recent, # long-form Lenny's Podcast episode without copying a transcript. diff --git a/tests/test_linters.py b/tests/test_linters.py index 1e6d720..efdb4cb 100644 --- a/tests/test_linters.py +++ b/tests/test_linters.py @@ -160,6 +160,35 @@ def test_check_versions_covers_newest_examples(): assert "OK examples/deck-review.html" in r.stdout +def test_check_contracts_clean(): + r = _run("scripts/check-contracts.mjs") + assert r.returncode == 0, r.stdout + r.stderr + assert "12 shapes" in r.stdout + assert "10 primitives" in r.stdout + + +def test_check_contracts_fails_when_json_drops_shape_still_named_in_skill(tmp_path): + import json + import shutil + + (tmp_path / "contracts").mkdir() + shapes = json.loads((REPO / "contracts" / "shapes.json").read_text()) + dropped = shapes["shapes"].pop(0) + (tmp_path / "contracts" / "shapes.json").write_text(json.dumps(shapes)) + shutil.copy(REPO / "contracts" / "primitives.json", tmp_path / "contracts" / "primitives.json") + for name in ("SKILL.md", "examples", "references", "scripts"): + (tmp_path / name).symlink_to(REPO / name) + r = subprocess.run( + [NODE, str(REPO / "scripts" / "check-contracts.mjs"), "--root", str(tmp_path)], + cwd=tmp_path, + capture_output=True, + text=True, + ) + assert r.returncode == 1, r.stdout + r.stderr + out = r.stdout + r.stderr + assert dropped["id"] in out or "expected 12 shapes" in out + + # --- review-contracts.mjs ----------------------------------------------------- def test_review_contracts_clean(): diff --git a/tests/test_perf_harness.py b/tests/test_perf_harness.py index a96e25c..0110287 100644 --- a/tests/test_perf_harness.py +++ b/tests/test_perf_harness.py @@ -72,6 +72,12 @@ def test_primitives_have_gallery_files_and_contracts(): for p in audit["primitives"]) +def test_harness_shape_count_matches_registry(): + h = _load_harness() + registry = json.loads((REPO / "contracts" / "shapes.json").read_text()) + assert list(h.SHAPE_REFERENCES) == [row["id"] for row in registry["shapes"]] + + def test_shapes_have_gallery_files_references_and_contracts(): h = _load_harness() audit = h.analyze_shapes()