API — @responsivejs/contract
The design-contract DSL: the spec lives here (types, registry, builder, loader, JSON Schema); execution (verifyContract, recordBaseline) lives in @responsivejs/design and is re-exported from it.
The format (version 1)
{
"version": 1,
"name": "home",
"viewport": { "widths": [320, 768, 1280], "height": 900 }, // or from/to/step
"selectors": { "sidebar": ".app-sidebar" }, // "$sidebar" aliases
"designSystem": { "profile": "material-design-3" }, // or inline config
"rules": [
{
"id": "sidebar-mobile", // stable id (derived if omitted)
"assert": "hidden", // one of the 27 registry constraints
"args": { "selector": "$sidebar" }, // named args, validated per constraint
"when": { "max": 767 }, // inclusive width range
"severity": "error", // override (error | warning | info)
"description": "sidebar collapses on mobile" // authored intent, shown to agents
}
],
"score": [{ "min": 0.6, "metrics": { "balance": 0.5 }, "scope": "main", "when": { "min": 768 } }],
"baselines": [{ "selector": "$sidebar", "prop": "width", "tolerance": { "px": 4 } }]
}JSON Schema: schema/design-contract.v1.json — generated from the registry, drift-tested. Versioning: additive optional fields don't bump version; changed semantics do. Unknown fields and newer versions are rejected with an upgrade hint.
Component contracts
A contract sweeps either the viewport or a container:
{
"name": "card", "version": 1,
"container": { "harness": ".story-harness", "widths": [240, 360, 480] },
"rules": [{ "assert": "noOverflow", "description": "the card never bleeds out of its container" }]
}In container mode the harness element is resized instead of the window, and everything is measured inside it, relative to it — so noOverflow compares against the component's width, not the page's. Container queries respond, because the harness is given container-type: inline-size. contractSweepPlan() reports harness, and rjs verify switches to a HarnessSource automatically: one component, one contract, verifiable in CI without a page around it.
The registry
CONSTRAINT_REGISTRY — 27 entries, one per Asserter constraint (see design), each declaring named params, types, and the positional mapping. It is the single source of truth for the TS types (ConstraintName), the runtime validator, the generated schema, and design's dispatch — a sync test against the real Asserter class prevents drift. Also: CONSTRAINT_NAMES, isConstraintName.
Builder
contract(name?) // → ContractBuilder
.viewport({ widths: [...] })
.select('alias', '.selector') // registers "$alias"
.use('material-design-3', selectors?) // embed a DS profile (or inline config)
.at('*' | { min?, max? }) // scope subsequent asserts (describe-block style)
.below(768) / .upTo(1024) / .from(768) / .between(a, b)
.assert(name, args?, { id?, severity?, description? })
.score({ min?, metrics?, scope?, when? })
.baseline(selector, prop, tolerance?)
.build() // validated DesignContract (throws ContractValidationError)
.toJSON()Round-trip guarantee: parseContract(builder.toJSON()) is the identity.
Loader
parseContract(json | object): DesignContract— throwsContractValidationErrorwith all issues formatted.validateContract(input)—{ contract, issues: [] }or{ contract: null, issues }; eachContractIssueis{ path, message, suggestion? }with did-you-mean suggestions for misspelled constraints, args, and fields.resolveAliases(contract)— expand"$alias"strings (whole-string matches only).
Zero dependencies — the validator is hand-rolled from the registry, not ajv.
Execution (from @responsivejs/design)
verifyContract(contract, store): ContractReport // sync, driver-free
verifyContract(contract, page, { height? }): Promise<...> // sweeps first
recordBaseline(contract, store): DesignContract // fills baselines[].curve
contractFromManifest(manifest, { name? }): { contract, skipped } // constructs → contractcontractFromManifest is the pure half of rjs init: a provenance manifest in, a loader-valid contract out (fluid → monotonic + continuous + baseline, ratio → proportion, breakpoints → viewport.widths, plus the noOverflow default). skipped lists every construct/prop that could not become a rule — report it, never swallow it.
Semantics:
- Scoping — each rule runs on a sub-store filtered to its
whenrange; an empty range makes the ruleskipped: true(reported, never failing). - Attribution — one
Asserterper rule: every violation carriesruleIdandruleDescription;severityoverrides apply per rule. - Scores — thresholds check the average, or every in-range width when
whenis set; failures surface both asScoreCheckResultand as syntheticscore.<metric>violations. - Baselines — recorded curves compared at their exact widths against
{ px?, percent? }tolerance (default 2px); unrecorded baselines reportunrecordedwithout failing. - Page overload — selectors and widths are derived from the contract itself (union of all selector args + DS selectors + score scopes + baseline selectors;
bp±1added forbreakpointSaferules). pass— noerror-severity violations (warnings/info don't fail).
ContractReport: { contract, pass, total, passed, failed, rules: RuleResult[], violations: ContractViolation[], score?, baselines? } — the flat violations list is the single agent surface.
The agent loop
verifyContract→ report.- For each violation:
ruleId→ look up the rule → readdescription(intent) and the violation'sfix({ selector, property, value, reason, kind }—kind: 'exact'is apply-verbatim;'heuristic'is a direction that needs judgment). - Patch, re-verify.
recordBaselineafter an approved visual change re-pins the curves.
Also exported
buildJsonSchema() | the JSON Schema generated from the registry — the same file committed under schema/, so tooling can regenerate instead of vendoring |
inRange(width, when) / describeRange(when) | the when: { min, max } scoping logic and its human-readable form, for tools that report which rules applied where |