affordance-spec
v0.1.0
Published
A schema-validated spec for affordance maps — nodes, edges, and an engagement overlay — plus an exported validator and a reference force-directed viewer.
Downloads
39
Maintainers
Readme
affordance-spec
A schema-validated spec for mapping a codebase and the affordances that hang off it. Its foundation (L1) is an affordance map — the pages, API routes, storage buckets, DB tables, components, external services, and infrastructure of a codebase, plus the relationships between them and an auth/access overlay — and an engagement overlay (L2) keys affordances and flows onto those nodes.
This repo is three things:
- Two specs —
AFFORDANCE-MAP-SPEC.md(L1, the static catalog) andENGAGEMENT-SPEC.md(L2, users flowing through it), each the prose companion to a canonical Zod schema (src/schema.ts,src/engagement-schema.ts→ TypeScript types → runtime validation → published JSON Schemas underschema/). - An exported validator — published as the
affordance-specpackage, so any producer (e.g. a static scanner) can validate its output against the contract. - A reference viewer — an interactive, force-directed renderer for any conforming map. It ships a small synthetic dataset so it runs out of the box.
The map data is produced by a producer (a scanner or generator) that emits a document conforming to the schema. Producers are codebase-specific and live outside this repo; the schema is the contract between them and any consumer.
How the two specs relate
L1 describes the possible (what exists); L2 describes the actual (recorded sessions) and the intended (authored flows). Their overlap is deliberately narrow:
- ResourceID — L1's durable node identity (
AFFORDANCE-MAP-SPEC.md§5) is the id space L2 keys on, but loosely: alignment is a re-runnable projection, never a stored foreign key, so L1 can regenerate without invalidating recordings. - Route templates — both use templates, never concrete URLs
(
page:/items/[id], not/items/123), so the two layers share one id space. - Envelope conventions — both documents carry a literal
versionwith the same bump policy, ISO-8601 UTC timestamps, and L2'sappnames the L1sourceit targets.
Two ideas make a map readable at scale:
- Infrastructure vs. services. A dependency consumed near-universally (e.g.
the database, used by 150+ nodes) is reclassified from an
external serviceinto its owninfracategory, so the genuinely-external integrations stay legible instead of drowning in plumbing edges. - Isolated nodes. Once infrastructure carries its own edges, a node with no edges at all is interesting — a static page, a presentational component, or a pure-logic route. These render as hollow dashed rings and can be spotlighted from the sidebar's ⊘ Isolated chip.
Interactive docs
Three sidecar Vite sites render the spec (in each: npm install && npm run dev,
or npm run build for a portable single-file dist/index.html):
spec-docs/— the unified reference: both specs on one page, the overlap made explicit (with a live alignment demo), validators for all three documents, and a downloadable LLM-markdown flattening of the whole spec.schema-docs/— the L1 map contract in depth.engagement-docs/— the L2 engagement contract in depth.
Quick start
npm install
npm run dev # open the reference viewer (renders the bundled demo data)To produce a single, self-contained dist/index.html (works offline, no server):
npm run build
# open dist/index.htmlScripts
| script | what it does |
| ------------------ | ------------------------------------------------------------------- |
| npm run dev | Vite dev server for the reference viewer |
| npm run build | Type-check + bundle the viewer to a single-file dist/index.html |
| npm run schema | Emit the JSON Schemas under schema/ from the Zod source |
| npm run build:lib| Build the validator package (dist-lib/, JS + .d.ts) |
| npm run typecheck| tsc --noEmit |
Using the validator
A producer depends on the package and validates its output before writing it:
import { parseAffordanceMap, type AffordanceMap } from 'affordance-spec';
const map: AffordanceMap = parseAffordanceMap(draft); // throws on any violationparseAffordanceMap() validates at runtime — including referential
integrity (every edge endpoint must be a real node). The engagement overlay
(L2) is exported the same way (parseEngagementLog, parseAffordanceCatalog).
The bundle — one file, both specs
The artifacts keep three lifecycles (generated map / captured recordings /
curated catalog), but for transport a spec bundle
(BUNDLE-SPEC.md, src/bundle-schema.ts,
parseSpecBundle) wraps any subset of them in a single JSON an importer can
sniff by its kind:
{
"version": 1, // envelope version (parts version themselves)
"kind": "spec-bundle", // discriminator for importers
"bundledAt": "2026-07-05T12:00:00.000Z",
"app": "example-app", // optional, informational
"map": { /* AffordanceMap */ }, // optional
"engagement": { /* EngagementLog */ }, // optional — sessions[] holds many recorded journeys
"catalog": { /* AffordanceCatalog */ } // optional — flows[] holds many named flows
}At least one part is required. Each part validates against its own schema,
unchanged, and multiplicity lives inside the parts: engagement.sessions is
an array (merge several recordings into one log rather than shipping several
bundles) and catalog.flows is an array (a whole library of named flows can
travel with the affordances their steps reference). Because the bundle is the
one place the artifacts travel together, it also cross-checks them: a
catalog bundled with a map must reference nodes that map actually has
(ENGAGEMENT-SPEC.md §8); recordings stay exempt — they re-align by
projection.
For tooling, npm run schema also emits a single combined JSON Schema,
schema/affordance-spec.schema.json —
every document type as a named definition (#/definitions/AffordanceMap,
EngagementLog, AffordanceCatalog, SpecBundle as root), so one file
covers everything a tool needs to validate.
Bringing your own data
The viewer renders src/data/affordance-map.json,
which ships as a synthetic example-app. To view a real codebase, point a
producer at it and drop the resulting (schema-valid) document in that slot — it
is plain data, so a local override needn't be committed.
The schema
src/schema.ts is the single source of truth (Zod). From it:
- TypeScript types (
AffordanceMap,Node,Edge, …) are inferred. parseAffordanceMap()validates data at runtime.npm run schemaemits the portable JSON Schema.
Shape
{
"version": 1,
"generatedAt": "2026-01-01T00:00:00.000Z",
"source": "example-app",
"nodes": [
{ "id": "page:/dashboard", "type": "page", "label": "/dashboard", "route": "/dashboard",
"auth": "auth", "file": "src/app/dashboard/page.tsx", "counts": { "apis": 1 } },
{ "id": "/api/items", "type": "api", "label": "items", "verbs": ["GET", "POST"],
"auth": "auth", "file": "src/app/api/items/route.ts",
"counts": { "tables": 1, "buckets": 0, "services": 0 } },
{ "id": "table:items", "type": "table", "label": "items" },
{ "id": "infra:Database", "type": "infra", "label": "Database", "consumers": 7 }
],
"edges": [
{ "from": "page:/dashboard", "to": "/api/items", "kind": "page-api" },
{ "from": "/api/items", "to": "table:items", "kind": "api-table" },
{ "from": "/api/items", "to": "infra:Database", "kind": "api-infra" }
],
"summary": { "pages": 1, "apis": 1, "components": 0, "tables": 1,
"buckets": 0, "services": 0, "infra": 1, "edges": 3 }
}type is a discriminated union — page/api/component carry extra fields
(route, verbs, auth, counts); table/bucket/service are bare nodes;
infra carries a consumers count (its universality).
Source layout
src/
schema.ts canonical Zod schema + types + validation
engagement-schema.ts L2 engagement overlay schema
index.ts package entry — re-exports schemas, types, validators
theme.ts palette, type/auth metadata
model.ts GraphModel: positions, adjacency, neighbourhoods
dom.ts tiny HTML/SVG element helpers
graph/
simulation.ts dependency-free force-directed layout
view.ts pan/zoom transform
renderer.ts SVG element management + per-frame draw
ui/
sidebar.ts search, type filters, grouped node list
detail.ts selected-node metadata + connections
main.ts wiring: data -> model -> sim/renderer/ui + interactions
data/
affordance-map.json bundled synthetic demo data
scripts/
emit-schema.ts Zod -> JSON SchemaNotes
The viewer renders whatever validates against the schema. auth levels and
counts are producer-derived — a directional overlay, not a security
audit — and are only as accurate as the producer that emitted them.
