Skip to content

Commit 1badea6

Browse files
jbernard077claude
andcommitted
feat(registry): declared control surface — schema field, catalog controls table, contributor docs
Adds the control-surface convention for registry items: - registry-item.json schema: optional controls field — name, type (toggle/variant/scalar), values/fields, min/max, default, drives; variant requires values, toggle forbids them, scalar requires both bounds. - generate-catalog-pages.ts: renders the declaration as a Controls table on the item's generated catalog page; the section is generator-owned (GENERATED_HEADINGS) so it regenerates and disappears with the manifest. - CONTROL_TYPES constant + updated enum drift-guard test in @hyperframes/core. - hyperframes-registry skill docs: the Control surface build rule (budget, three control types, mapping laws, reserved controls, CONFIG comment grammar), a Clarify-step question, Quality Gate checkboxes + reviewer audit procedure, controls declaration in the templates and manifest-fields docs, and the component examples rewritten to the CONFIG-reading-helper pattern (raw tween args never migrate into host code). The field is optional today and required at the Quality Gate for new items; existing items are unaffected (regenerating the catalog with no declarations is a byte-identical no-op). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5b45bcc commit 1badea6

12 files changed

Lines changed: 285 additions & 35 deletions

File tree

‎packages/core/schemas/registry-item.json‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,76 @@
125125
"poster": { "type": "string" }
126126
}
127127
},
128+
"controls": {
129+
"type": "array",
130+
"minItems": 1,
131+
"description": "The item's declared control surface — the single authoring source for control documentation. Each entry mirrors one control in the item's top-of-script CONFIG object and is rendered as a controls table on the item's generated catalog page.",
132+
"items": {
133+
"type": "object",
134+
"required": ["name", "type", "drives"],
135+
"additionalProperties": false,
136+
"properties": {
137+
"name": {
138+
"type": "string",
139+
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$",
140+
"description": "Control name as it appears in CONFIG. Intent-named (e.g. \"energy\", \"layout\"), never implementation-named (e.g. \"staggerMs\")."
141+
},
142+
"type": {
143+
"type": "string",
144+
"enum": ["toggle", "variant", "scalar"],
145+
"description": "toggle = boolean between two authored states; variant = enum over N authored poses; scalar = bounded number interpolating authored anchor states through a mapping."
146+
},
147+
"values": {
148+
"type": "array",
149+
"minItems": 2,
150+
"items": { "type": "string", "minLength": 1 },
151+
"description": "variant only: the authored poses. Toggles omit this (always true | false)."
152+
},
153+
"fields": {
154+
"type": "array",
155+
"minItems": 2,
156+
"items": { "type": "string", "minLength": 1 },
157+
"description": "Maps one declared control to N CONFIG keys (e.g. a paired animationIn/animationOut toggle counting as one control). default is then keyed per field."
158+
},
159+
"min": {
160+
"type": "number",
161+
"description": "scalar only: lower declared bound (out-of-bounds input clamps here, loudly)."
162+
},
163+
"max": {
164+
"type": "number",
165+
"description": "scalar only: upper declared bound (out-of-bounds input clamps here, loudly)."
166+
},
167+
"default": {
168+
"description": "The shipped state: variant = one of values; toggle = boolean (or a per-field object when fields is set); scalar = a number within min..max."
169+
},
170+
"drives": {
171+
"type": "string",
172+
"minLength": 1,
173+
"description": "What the control drives — the designed state group or mapping behind it."
174+
}
175+
},
176+
"allOf": [
177+
{
178+
"if": {
179+
"required": ["type"],
180+
"properties": { "type": { "const": "variant" } }
181+
},
182+
"then": { "required": ["values"] },
183+
"else": { "not": { "required": ["values"] } }
184+
},
185+
{
186+
"if": {
187+
"required": ["type"],
188+
"properties": { "type": { "const": "scalar" } }
189+
},
190+
"then": { "required": ["min", "max"] },
191+
"else": {
192+
"not": { "anyOf": [{ "required": ["min"] }, { "required": ["max"] }] }
193+
}
194+
}
195+
]
196+
}
197+
},
128198
"relatedSkill": {
129199
"type": "string",
130200
"minLength": 1

‎packages/core/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ export type {
352352
export {
353353
ITEM_TYPES,
354354
FILE_TYPES,
355+
CONTROL_TYPES,
355356
ITEM_TYPE_DIRS,
356357
isExampleItem,
357358
isBlockItem,

‎packages/core/src/registry/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type {
1818
export {
1919
ITEM_TYPES,
2020
FILE_TYPES,
21+
CONTROL_TYPES,
2122
ITEM_TYPE_DIRS,
2223
BLOCK_CATEGORIES,
2324
resolveBlockCategory,

‎packages/core/src/registry/types.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
33
import { fileURLToPath } from "node:url";
44
import { dirname, resolve } from "node:path";
55
import {
6+
CONTROL_TYPES,
67
FILE_TYPES,
78
ITEM_TYPES,
89
isBlockItem,
@@ -120,13 +121,15 @@ describe("registry types", () => {
120121
expect(setEquals(enums[0]!, ITEM_TYPES)).toBe(true);
121122
});
122123

123-
it("registry-item.json has exactly two `type` enums: one ITEM_TYPES, one FILE_TYPES", () => {
124+
it("registry-item.json has exactly three `type` enums: ITEM_TYPES, FILE_TYPES, CONTROL_TYPES", () => {
124125
const enums = collectEnums(registryItemSchema, "type");
125126
const distinct = new Set(enums.map(setKey));
126-
// Two semantically distinct enums — the item's `type` and each file's `type`.
127-
expect(distinct.size).toBe(2);
127+
// Three semantically distinct enums — the item's `type`, each file's
128+
// `type`, and each declared control's `type`.
129+
expect(distinct.size).toBe(3);
128130
expect(enums.some((e) => setEquals(e, ITEM_TYPES))).toBe(true);
129131
expect(enums.some((e) => setEquals(e, FILE_TYPES))).toBe(true);
132+
expect(enums.some((e) => setEquals(e, CONTROL_TYPES))).toBe(true);
130133
});
131134
});
132135

‎packages/core/src/registry/types.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ export const FILE_TYPES = [
153153
"hyperframes:timeline",
154154
] as const satisfies readonly FileType[];
155155

156+
/**
157+
* The declared-control types in a registry item's `controls` field: toggle
158+
* (boolean between two authored states), variant (enum over N authored poses),
159+
* scalar (bounded number interpolating authored anchor states through a
160+
* mapping). Kept in sync with the `controls` enum in registry-item.json.
161+
*/
162+
export const CONTROL_TYPES = ["toggle", "variant", "scalar"] as const;
163+
156164
/**
157165
* Directory segment where each item type lives under a registry root — both
158166
* on disk (`registry/examples/…`) and in URL construction

‎scripts/generate-catalog-pages.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ function discoverItems(): { kind: ItemKind; manifest: RegistryItem }[] {
177177
const GENERATED_HEADINGS = new Set([
178178
// current template
179179
"install",
180+
"controls",
180181
"variables",
181182
"source",
182183
"add it to your video",
@@ -780,6 +781,77 @@ function generateVariables(manifest: RegistryItem): string[] {
780781
* not installed yet. Collapsed because these run to several hundred lines and an
781782
* expanded wall of markup would push everything else off the page.
782783
*/
784+
interface ItemControl {
785+
name: string;
786+
type: "toggle" | "variant" | "scalar";
787+
values?: string[];
788+
fields?: string[];
789+
min?: number;
790+
max?: number;
791+
default?: unknown;
792+
drives: string;
793+
}
794+
795+
/** Pipe characters break table cells; everything user-authored passes through here. */
796+
function tableCell(value: string): string {
797+
return value.replace(/\|/g, "\\|");
798+
}
799+
800+
/** The allowed inputs for one control, written the way a reader has to type them. */
801+
function controlRange(c: ItemControl): string {
802+
if (c.type === "toggle") return "`true` / `false`";
803+
if (c.type === "variant" && c.values?.length) {
804+
return c.values.map((v) => `\`${v}\``).join(", ");
805+
}
806+
if (c.type === "scalar") {
807+
if (typeof c.min === "number" && typeof c.max === "number") return `\`${c.min}\`..\`${c.max}\``;
808+
return "bounded number";
809+
}
810+
return c.type;
811+
}
812+
813+
/** The shipped state. Per-field defaults (paired toggles) list each field's value. */
814+
function controlDefault(c: ItemControl): string {
815+
if (c.default === undefined) return "—";
816+
if (typeof c.default === "object" && c.default !== null) {
817+
const entries = Object.entries(c.default as Record<string, unknown>);
818+
if (entries.length === 0) return "—";
819+
return entries.map(([k, v]) => `\`${k}: ${String(v)}\``).join(", ");
820+
}
821+
return `\`${String(c.default)}\``;
822+
}
823+
824+
/**
825+
* The item's declared control surface, from `controls` in registry-item.json —
826+
* the catalog-page render of the item's published-parameter list. Items without
827+
* a declaration get no section (the field is optional until the gate requires it).
828+
*/
829+
function generateControls(manifest: RegistryItem): string[] {
830+
const raw = (manifest as RegistryItem & { controls?: ItemControl[] }).controls;
831+
if (!Array.isArray(raw) || raw.length === 0) return [];
832+
833+
const lines: string[] = [
834+
"## Controls",
835+
"",
836+
"The designed levers this item publishes, set in its top-of-script `CONFIG`",
837+
"object. Anything not listed here — and not a content field or a documented",
838+
"CSS token — is locked: changing it means editing the item source.",
839+
"",
840+
"| Control | Type | Accepts | Default | Drives |",
841+
"| --- | --- | --- | --- | --- |",
842+
];
843+
for (const c of raw) {
844+
const name = c.fields?.length
845+
? `\`${c.name}\` (${c.fields.map((f) => `\`${f}\``).join(" + ")})`
846+
: `\`${c.name}\``;
847+
lines.push(
848+
`| ${tableCell(name)} | ${c.type} | ${tableCell(controlRange(c))} | ${tableCell(controlDefault(c))} | ${tableCell(c.drives)} |`,
849+
);
850+
}
851+
lines.push("");
852+
return lines;
853+
}
854+
783855
function primarySource(
784856
kind: ItemKind,
785857
manifest: RegistryItem,
@@ -1061,6 +1133,7 @@ function generateItemMdx(
10611133
// their version is carried through below instead of being overwritten.
10621134
lines.push(...usageSection(kind, manifest, primaryTarget, carried, textureGroups));
10631135

1136+
lines.push(...generateControls(manifest));
10641137
lines.push(...generateParams(manifest));
10651138
lines.push(...generateVariables(manifest));
10661139
lines.push(...generateVariableUsage(manifest, primaryTarget));

‎skills-manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"files": 3
4747
},
4848
"hyperframes-registry": {
49-
"hash": "d6dfcc8ceb7c8178",
49+
"hash": "050e3047190cb1ef",
5050
"files": 12
5151
},
5252
"media-use": {

‎skills/hyperframes-registry/examples/add-component.md‎

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,24 +40,16 @@ document.querySelectorAll(".shimmer-sweep-target").forEach((el) => {
4040
});
4141
```
4242

43-
**Timeline** — add the sweep:
43+
**Timeline** — add the sweep at a position; the snippet's CONFIG-reading helper owns
44+
the sweep's internal duration, ease, and stagger:
4445

4546
```js
46-
tl.fromTo(
47-
".shimmer-sweep-target",
48-
{
49-
"--shimmer-pos": "-20%",
50-
},
51-
{
52-
"--shimmer-pos": "120%",
53-
duration: 1.2,
54-
ease: "power2.inOut",
55-
stagger: 0.15,
56-
},
57-
1.5,
58-
);
47+
tl.add(shimmerSweepBuild(".shimmer-sweep-target"), 1.5);
5948
```
6049

50+
The parent composition owns _where_ the sweep sits on the timeline (the position arg);
51+
the component owns _how_ it moves. Raw tween args never migrate into host code.
52+
6153
### 4. Lint and preview
6254

6355
```bash
@@ -67,7 +59,8 @@ hyperframes preview
6759

6860
### 5. Customize
6961

70-
- `--shimmer-color`: highlight color per element
71-
- `--shimmer-width`: light band width (default 20%)
72-
- `--shimmer-angle`: sweep direction (default 120deg)
73-
- Timeline `duration`, `ease`, `stagger`: control speed and feel
62+
- `sweep` in the snippet's `CONFIG`: `"subtle" | "standard" | "dramatic"` — drives band
63+
width, angle, and the helper's internal duration/ease/stagger
64+
- `--shimmer-color`: host re-skin token — override per element or at host level
65+
- Everything else (raw durations, eases, staggers, band geometry) is locked — changing it
66+
means editing the component source

0 commit comments

Comments
 (0)