dreative 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,7 +82,7 @@ export function runDocsCheck(skillDir) {
82
82
  add(findings, "recipes", specialist, "inline recipe catalog remains in the primary specialist file");
83
83
  }
84
84
  const planSchema = contents.get("schemas/plan.schema.json") ?? "";
85
- for (const field of ["doctrineVersion", "ruleExceptions", "creativeStrategy", "fontDecision", "experimentalPlan", "conceptExploration", "recipeAccess"]) {
85
+ for (const field of ["doctrineVersion", "ruleExceptions", "creativeStrategy", "motionComplexityBudget", "motionTreatment", "preparation", "fontDecision", "experimentalPlan", "conceptExploration", "recipeAccess"]) {
86
86
  if (!planSchema.includes(`\"${field}\"`))
87
87
  add(findings, "schema", "schemas/plan.schema.json", `missing creative-control field ${field}`);
88
88
  }
@@ -59,6 +59,26 @@ export function validatePlan(value) {
59
59
  errors.push(`${prefix} is still planned`);
60
60
  if ((section.status === "fallback" || section.status === "cut") && !nonEmpty(section.reason))
61
61
  errors.push(`${prefix}.reason is required for ${section.status}`);
62
+ if (section.motionTreatment) {
63
+ const treatment = section.motionTreatment;
64
+ if (!["none", "decorative", "structural", "transformational"].includes(treatment.class))
65
+ errors.push(`${prefix}.motionTreatment.class is invalid`);
66
+ for (const [name, value] of Object.entries({
67
+ staticComposition: treatment.staticComposition,
68
+ startState: treatment.startState,
69
+ endState: treatment.endState,
70
+ handoff: treatment.handoff,
71
+ purpose: treatment.purpose,
72
+ mechanism: treatment.mechanism,
73
+ mobile: treatment.mobile,
74
+ reducedMotion: treatment.reducedMotion,
75
+ })) {
76
+ if (!nonEmpty(value))
77
+ errors.push(`${prefix}.motionTreatment.${name} is required`);
78
+ }
79
+ if (!Array.isArray(treatment.changes) || !Array.isArray(treatment.pinnedElements))
80
+ errors.push(`${prefix}.motionTreatment changes and pinnedElements must be arrays`);
81
+ }
62
82
  for (const skill of section.skills ?? []) {
63
83
  if (!page.skills?.includes(skill))
64
84
  errors.push(`${prefix} uses skill ${skill} not assigned to its page`);
@@ -66,6 +86,20 @@ export function validatePlan(value) {
66
86
  for (const asset of section.assets ?? []) {
67
87
  if (!nonEmpty(asset.id) || !nonEmpty(asset.path) || !nonEmpty(asset.purpose))
68
88
  errors.push(`${prefix} contains an incomplete asset`);
89
+ if (plan.doctrineVersion === 2 && (!asset.importance || !asset.preparation))
90
+ errors.push(`${prefix} asset ${asset.id || "unknown"} needs importance and a preparation decision`);
91
+ else if (asset.preparation) {
92
+ if (!asset.importance || !["supporting", "key"].includes(asset.importance))
93
+ errors.push(`${prefix} asset ${asset.id} has invalid importance`);
94
+ if (!["flat", "decompose", "variants", "sequence"].includes(asset.preparation.decision))
95
+ errors.push(`${prefix} asset ${asset.id} has an invalid preparation decision`);
96
+ if (!nonEmpty(asset.preparation.rationale))
97
+ errors.push(`${prefix} asset ${asset.id} needs a preparation rationale`);
98
+ if (!Array.isArray(asset.preparation.derivatives))
99
+ errors.push(`${prefix} asset ${asset.id} preparation.derivatives must be an array`);
100
+ if (asset.importance === "key" && asset.preparation.decision !== "flat" && asset.preparation.derivatives.length === 0)
101
+ errors.push(`${prefix} key asset ${asset.id} promises preparation but names no derivatives`);
102
+ }
69
103
  if ((asset.status === "fallback" || asset.status === "cut") && !nonEmpty(asset.reason))
70
104
  errors.push(`${prefix} asset ${asset.id} needs a reason`);
71
105
  }
@@ -132,11 +166,35 @@ export function validateVerificationReport(value) {
132
166
  errors.push("verification report version must be 1");
133
167
  if (!Array.isArray(report.evidence))
134
168
  errors.push("verification evidence must be an array");
169
+ if (report.refinement) {
170
+ if (!nonEmpty(report.refinement.inspectedAt) || Number.isNaN(Date.parse(report.refinement.inspectedAt)))
171
+ errors.push("verification refinement needs a valid inspectedAt timestamp");
172
+ if (!Array.isArray(report.refinement.findings) ||
173
+ report.refinement.findings.length === 0 ||
174
+ !Array.isArray(report.refinement.changes) ||
175
+ report.refinement.changes.length === 0 ||
176
+ !Array.isArray(report.refinement.evidenceIds) ||
177
+ report.refinement.evidenceIds.length === 0)
178
+ errors.push("verification refinement needs findings, changes, and evidenceIds arrays");
179
+ }
135
180
  if ((report.evidence ?? []).some((item) => item.status === "fail"))
136
181
  errors.push("verification report contains failing evidence");
182
+ const timelineStates = new Set([
183
+ "initial",
184
+ "early",
185
+ "mid-transition",
186
+ "final",
187
+ "handoff",
188
+ "pinned-midpoint",
189
+ "pinned-exit",
190
+ "mobile",
191
+ "reduced-motion",
192
+ ]);
137
193
  for (const [index, item] of (report.evidence ?? []).entries()) {
138
194
  if (!nonEmpty(item.id) || !nonEmpty(item.criterion) || !nonEmpty(item.evidence))
139
195
  errors.push(`verification.evidence[${index}] is incomplete`);
196
+ if (item.timelineState && !timelineStates.has(item.timelineState))
197
+ errors.push(`verification.evidence[${index}].timelineState is invalid`);
140
198
  const proof = item.proof;
141
199
  if (!proof || !nonEmpty(proof.timestamp) || Number.isNaN(Date.parse(proof.timestamp))) {
142
200
  errors.push(`verification.evidence[${index}].proof requires a valid timestamp`);
@@ -142,12 +142,13 @@ export function buildDesignPlan(page, brief, assignedSkills) {
142
142
  const motionBudget = dials.motion <= 3
143
143
  ? "hover/active states only, no scroll animation"
144
144
  : dials.motion <= 6
145
- ? "entry fade+rise on hero, whileInView reveals on 2-3 key sections, nothing infinite"
146
- : "scroll choreography allowed on ≤2 sections + hero entry; everything reduced-motion safe";
145
+ ? "purposeful local motion; use structural transitions only where content changes state, with visible defaults"
146
+ : "write section motion treatments; concentrate structural/transformational choreography into a few hero moments with calm sections, mobile translations, and reduced-motion states";
147
147
  const directives = [
148
148
  `aesthetic: ${aesthetic}; dials variance=${dials.variance} motion=${dials.motion} density=${dials.density}`,
149
149
  `spacing scale: ${spacing}`,
150
150
  `motion budget: ${motionBudget}`,
151
+ "choose the simplest mechanism that makes each intended transformation convincing; do not default blindly to fade/slide/scale or to WebGL/3D",
151
152
  "one accent color, one neutral family, one radius system, locked across ALL pages of this project",
152
153
  "section layout families are assigned below — follow them, do not default to repeated card rows",
153
154
  ...(dials.variance > 4 ? ["avoid centered-hero-over-gradient; use split or asymmetric composition"] : []),
@@ -9,6 +9,12 @@ function specific(value, minLength) {
9
9
  return false;
10
10
  return !/^(it |this )?(did not fit|didn't fit|felt better|was unnecessary|was not needed|looked better|fits? the design|restraint)$/i.test(value.trim());
11
11
  }
12
+ function genericMotionOnly(value) {
13
+ const text = normalized(value);
14
+ const basic = /\b(fade|opacity|translate|slide|scale|zoom|parallax|reveal|stagger)\b/;
15
+ const structural = /\b(mask|fragment|tile|pixel|layer|depth|pin|persist|handoff|morph|reconstruct|sequence|canvas|svg|webgl|shader|state|composition|camera|clip-path|typography)\b/;
16
+ return basic.test(text) && !structural.test(text);
17
+ }
12
18
  export function validateRuleControls(plan, registry, reflexFonts, verification) {
13
19
  if (plan.doctrineVersion !== 2)
14
20
  return [];
@@ -81,20 +87,104 @@ export function validateRuleControls(plan, registry, reflexFonts, verification)
81
87
  if (!strategy)
82
88
  errors.push(`${plan.tier} plans require a diversity-or-development creativeStrategy`);
83
89
  else if (strategy.path === "diversity") {
84
- if (distinct(strategy.mechanisms ?? []).length < 4)
85
- errors.push("diversity path requires four distinct mechanisms");
86
- if (distinct(strategy.drivers ?? []).length < 3)
87
- errors.push("diversity path requires three distinct drivers");
90
+ if (distinct(strategy.mechanisms ?? []).length === 0)
91
+ errors.push("diversity path requires at least one concept-specific mechanism");
92
+ if (distinct(strategy.drivers ?? []).length === 0)
93
+ errors.push("diversity path requires at least one meaningful driver");
88
94
  }
89
95
  else {
90
96
  if (!specific(strategy.signatureMechanism, 12))
91
97
  errors.push("development path requires a named signature mechanism");
92
- if (distinct(strategy.states ?? []).length < 3 || (strategy.states ?? []).some((state) => state.trim().length < 12))
93
- errors.push("development path requires three materially different described states");
94
- if (distinct(strategy.secondaryMechanisms ?? []).length < 2)
95
- errors.push("development path requires two quieter secondary mechanisms");
96
- if (distinct(strategy.drivers ?? []).length < 2)
97
- errors.push("development path requires two distinct drivers");
98
+ if (distinct(strategy.states ?? []).length < 2 || (strategy.states ?? []).some((state) => state.trim().length < 12))
99
+ errors.push("development path requires materially different described states");
100
+ if (!Array.isArray(strategy.secondaryMechanisms))
101
+ errors.push("development path must record its quieter supporting mechanisms");
102
+ if (distinct(strategy.drivers ?? []).length === 0)
103
+ errors.push("development path requires a meaningful driver");
104
+ }
105
+ const sections = plan.pages.flatMap((page) => page.sections);
106
+ const sectionIds = new Set(sections.map((section) => section.id));
107
+ const budget = plan.motionComplexityBudget;
108
+ if (!budget)
109
+ errors.push(`${plan.tier} plans require a contextual motionComplexityBudget`);
110
+ else {
111
+ if (budget.heroMoments.length === 0 || budget.heroMoments.length > 3)
112
+ errors.push("motion complexity must be concentrated into one to three hero moments");
113
+ for (const moment of budget.heroMoments) {
114
+ if (!sectionIds.has(moment.sectionId))
115
+ errors.push(`motion hero moment references unknown section ${moment.sectionId}`);
116
+ if (!specific(moment.reason, 20))
117
+ errors.push(`motion hero moment ${moment.sectionId} needs a concept-specific reason`);
118
+ }
119
+ for (const sectionId of budget.calmSectionIds) {
120
+ if (!sectionIds.has(sectionId))
121
+ errors.push(`motion calm section references unknown section ${sectionId}`);
122
+ }
123
+ if (!specific(budget.sharedLanguage, 20) || !specific(budget.deviceLimits, 20) || !specific(budget.progressiveEnhancement, 20))
124
+ errors.push("motion complexity budget needs shared language, device limits, and progressive-enhancement decisions");
125
+ const antiDefault = budget.antiDefaultReview;
126
+ if (!antiDefault)
127
+ errors.push("motion complexity budget requires the anti-default review");
128
+ else
129
+ for (const [name, answer] of Object.entries(antiDefault)) {
130
+ if (!specific(answer, 20))
131
+ errors.push(`anti-default review ${name} needs a concrete answer`);
132
+ }
133
+ }
134
+ const treatments = sections.map((section) => section.motionTreatment);
135
+ if (treatments.some((treatment) => !treatment))
136
+ errors.push(`${plan.tier} plans require a motion treatment for every major section, including calm sections`);
137
+ const significant = treatments.filter((treatment) => Boolean(treatment && (treatment.class === "structural" || treatment.class === "transformational")));
138
+ if (significant.length === 0)
139
+ errors.push(`${plan.tier} motion cannot be decorative-only`);
140
+ if (significant.length > 0 &&
141
+ significant.every((treatment) => genericMotionOnly(`${treatment.mechanism} ${(treatment.changes ?? []).join(" ")} ${treatment.handoff}`)))
142
+ errors.push(`${plan.tier} motion still describes only generic fade/slide/scale behavior; revise the scene architecture`);
143
+ for (const [index, treatment] of treatments.entries()) {
144
+ if (!treatment)
145
+ continue;
146
+ const prefix = `motion treatment ${sections[index].id}`;
147
+ for (const [name, value] of Object.entries({
148
+ staticComposition: treatment.staticComposition,
149
+ startState: treatment.startState,
150
+ endState: treatment.endState,
151
+ handoff: treatment.handoff,
152
+ purpose: treatment.purpose,
153
+ mechanism: treatment.mechanism,
154
+ mobile: treatment.mobile,
155
+ reducedMotion: treatment.reducedMotion,
156
+ })) {
157
+ if (!specific(value, 12))
158
+ errors.push(`${prefix}.${name} needs a concrete decision`);
159
+ }
160
+ }
161
+ if (verification) {
162
+ const timelineStates = new Set(verification.evidence.map((item) => item.timelineState).filter(Boolean));
163
+ const requiredStates = [
164
+ "initial",
165
+ "early",
166
+ "mid-transition",
167
+ "final",
168
+ "handoff",
169
+ "mobile",
170
+ "reduced-motion",
171
+ ];
172
+ if (treatments.some((treatment) => (treatment?.pinnedElements?.length ?? 0) > 0))
173
+ requiredStates.push("pinned-midpoint", "pinned-exit");
174
+ for (const state of requiredStates) {
175
+ if (!timelineStates.has(state))
176
+ errors.push(`visual verification is missing timeline state ${state}`);
177
+ }
178
+ const refinement = verification.refinement;
179
+ if (!refinement || refinement.changes.length === 0 || refinement.evidenceIds.length === 0)
180
+ errors.push(`${plan.tier} verification requires a recorded refinement pass after visual inspection`);
181
+ else {
182
+ for (const id of refinement.evidenceIds) {
183
+ const item = evidence.get(id);
184
+ if (!item || item.status !== "pass")
185
+ errors.push(`refinement references missing or failing evidence ${id}`);
186
+ }
187
+ }
98
188
  }
99
189
  }
100
190
  if (plan.skills.includes("experimental")) {
@@ -33,6 +33,20 @@ function basePlan(tier = "award") {
33
33
  mechanisms: ["variable-type morph", "depth-stack", "drag-inertia", "mask-dissolve"],
34
34
  drivers: ["scroll", "pointer", "time"],
35
35
  },
36
+ motionComplexityBudget: {
37
+ heroMoments: [{ sectionId: "hero", reason: "The archival specimen becomes the navigation system and establishes the living-catalog concept." }],
38
+ calmSectionIds: [],
39
+ sharedLanguage: "Archival sheets fold, align, and become structural boundaries across the journey.",
40
+ deviceLimits: "Desktop uses the full pinned chapter while mobile shortens travel and removes continuous rendering.",
41
+ progressiveEnhancement: "Semantic catalog content renders first; masks and synchronized motion enhance it when supported.",
42
+ antiDefaultReview: {
43
+ basicMotionAssessment: "The key specimen changes role and geometry rather than only fading, sliding, or scaling.",
44
+ compositionHandoff: "The specimen edge becomes the next chapter's indexing rail instead of disappearing.",
45
+ visualStateChange: "Loose sheets become navigation, a research surface, and then the closing mark.",
46
+ conceptSpecificity: "The sheet behavior is derived from archival handling and would not fit an unrelated product.",
47
+ memorableMoment: "Dragging the specimen reorganizes the complete catalog into the next chapter.",
48
+ },
49
+ },
36
50
  pages: [
37
51
  {
38
52
  id: "home",
@@ -45,6 +59,19 @@ function basePlan(tier = "award") {
45
59
  layoutFamily: "archival-index",
46
60
  skills: ["ux", "mobile", "motion", "media"],
47
61
  interactions: ["drag specimen"],
62
+ motionTreatment: {
63
+ class: "transformational",
64
+ staticComposition: "A loose archival specimen sits beside the catalog index.",
65
+ startState: "The specimen is centered and isolated as the page's primary subject.",
66
+ endState: "The specimen unfolds into the structural rail for the next catalog chapter.",
67
+ changes: ["The specimen mask expands and its edge becomes the next chapter rail."],
68
+ pinnedElements: [],
69
+ handoff: "The specimen edge persists as the indexing rail in the following composition.",
70
+ purpose: "Turn passive archival viewing into active handling and establish continuity.",
71
+ mechanism: "SVG mask migration synchronized with a GSAP scroll timeline.",
72
+ mobile: "A shorter clip-path handoff preserves the specimen-to-rail transformation.",
73
+ reducedMotion: "Render the specimen already aligned with the rail and use a direct section cut.",
74
+ },
48
75
  mobile: "stacked specimen",
49
76
  fallback: "static catalog",
50
77
  verification: ["Signature responds to input"],
@@ -59,41 +86,59 @@ function basePlan(tier = "award") {
59
86
  };
60
87
  }
61
88
  function verification(...ids) {
89
+ const timelineStates = ["initial", "early", "mid-transition", "final", "handoff", "mobile", "reduced-motion"];
90
+ const timeline = timelineStates.map((timelineState) => ({
91
+ id: `timeline-${timelineState}`,
92
+ criterion: `Captured ${timelineState} visual state`,
93
+ status: "pass",
94
+ evidence: "Captured the production build at the planned visual state.",
95
+ timelineState,
96
+ proof: { timestamp: started, testedUrl: "http://localhost:3000", consoleErrorCount: 0 },
97
+ }));
62
98
  return {
63
99
  version: 1,
64
100
  generatedAt: started,
65
- evidence: ids.map((id) => ({
66
- id,
67
- criterion: id,
68
- status: "pass",
69
- evidence: "Captured runtime behavior in the production build.",
70
- proof: { timestamp: started, testedUrl: "http://localhost:3000", consoleErrorCount: 0 },
71
- })),
101
+ evidence: [
102
+ ...timeline,
103
+ ...ids.map((id) => ({
104
+ id,
105
+ criterion: id,
106
+ status: "pass",
107
+ evidence: "Captured runtime behavior in the production build.",
108
+ proof: { timestamp: started, testedUrl: "http://localhost:3000", consoleErrorCount: 0 },
109
+ })),
110
+ ],
111
+ refinement: {
112
+ inspectedAt: started,
113
+ findings: ["The mid-transition index was too compressed to read."],
114
+ changes: ["Extended the mask handoff and delayed the rail compression."],
115
+ evidenceIds: ["timeline-mid-transition"],
116
+ },
72
117
  };
73
118
  }
74
119
  test("valid default award plan", () => {
75
120
  assert.deepEqual(validateRuleControls(basePlan(), registry, reflex, verification()), []);
76
121
  });
77
- test("valid 3D substitution using spatial typography", () => {
122
+ test("valid key-asset substitution for a typography-only experience", () => {
78
123
  const plan = basePlan();
79
124
  plan.ruleExceptions = [
80
125
  {
81
- ruleId: "award.spatialSignature",
126
+ ruleId: "media.keyAssetTreatment",
82
127
  decision: "substituted",
83
128
  declaredAt: planned,
84
- reason: "The archival concept has no physical subject, so a product-like prop would misrepresent the material.",
85
- alternative: "A persistent variable-type specimen becomes index, spatial architecture, and interactive control across four chapters.",
86
- successCriteria: ["The specimen appears in three materially different roles", "Pointer and scroll input visibly reshape its depth"],
87
- evidenceIds: ["spatial-type-desktop", "spatial-type-mobile"],
129
+ reason: "The archive contains no raster key imagery; inventing photography would misrepresent the supplied typographic material.",
130
+ alternative: "A persistent variable-type specimen becomes index, spatial architecture, and interactive control across the complete journey.",
131
+ successCriteria: ["The specimen appears in materially different structural roles", "Pointer and scroll input visibly reshape the specimen"],
132
+ evidenceIds: ["type-system-desktop", "type-system-mobile"],
88
133
  },
89
134
  ];
90
- assert.deepEqual(validateRuleControls(plan, registry, reflex, verification("spatial-type-desktop", "spatial-type-mobile")), []);
135
+ assert.deepEqual(validateRuleControls(plan, registry, reflex, verification("type-system-desktop", "type-system-mobile")), []);
91
136
  });
92
- test("invalid vague 3D exception", () => {
137
+ test("invalid vague creative-rule exception", () => {
93
138
  const plan = basePlan();
94
139
  plan.ruleExceptions = [
95
140
  {
96
- ruleId: "award.spatialSignature",
141
+ ruleId: "media.keyAssetTreatment",
97
142
  decision: "substituted",
98
143
  declaredAt: planned,
99
144
  reason: "It did not fit",
@@ -135,11 +180,41 @@ test("valid development plan", () => {
135
180
  };
136
181
  assert.deepEqual(validateRuleControls(plan, registry, reflex, verification()), []);
137
182
  });
138
- test("invalid repeated-mechanism plan", () => {
183
+ test("invalid decorative-only expressive plan", () => {
139
184
  const plan = basePlan();
140
- plan.creativeStrategy = { path: "diversity", mechanisms: ["fade", "fade", "fade", "fade"], drivers: ["scroll", "scroll", "scroll"] };
185
+ plan.pages[0].sections[0].motionTreatment = {
186
+ ...plan.pages[0].sections[0].motionTreatment,
187
+ class: "decorative",
188
+ changes: ["The image fades upward and scales into view."],
189
+ mechanism: "CSS opacity, translateY, and scale transitions on scroll reveal.",
190
+ };
141
191
  const errors = validateRuleControls(plan, registry, reflex, verification());
142
- assert.ok(errors.some((error) => error.includes("distinct mechanisms")));
192
+ assert.ok(errors.some((error) => error.includes("decorative-only")));
193
+ });
194
+ test("expressive plan requires contextual motion budget", () => {
195
+ const plan = basePlan("expressive");
196
+ delete plan.motionComplexityBudget;
197
+ assert.ok(validateRuleControls(plan, registry, reflex, verification()).some((error) => error.includes("motionComplexityBudget")));
198
+ });
199
+ test("award verification requires timeline states and a refinement pass", () => {
200
+ const plan = basePlan();
201
+ const report = {
202
+ version: 1,
203
+ generatedAt: started,
204
+ evidence: [
205
+ {
206
+ id: "initial-only",
207
+ criterion: "Initial state",
208
+ status: "pass",
209
+ evidence: "Captured only the initial frame.",
210
+ timelineState: "initial",
211
+ proof: { timestamp: started, testedUrl: "http://localhost:3000", consoleErrorCount: 0 },
212
+ },
213
+ ],
214
+ };
215
+ const errors = validateRuleControls(plan, registry, reflex, report);
216
+ assert.ok(errors.some((error) => error.includes("mid-transition")));
217
+ assert.ok(errors.some((error) => error.includes("refinement pass")));
143
218
  });
144
219
  test("valid reflex-font justification", () => {
145
220
  const plan = basePlan("premium");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreative",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Frontend design skill for coding CLIs with typed planning, specialist treatments, verification, and an optional visual editor.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -156,7 +156,7 @@ its own plan didn't describe. Hard rules for any expressive+ no-media build:
156
156
  the blueprint's layout-family words couldn't match them, the build
157
157
  restyled a card list and called it the spine — that is a depth-honesty
158
158
  failure (§11), not a detail.
159
- 2. **≥ 2 bespoke drawn/procedural artifacts.** With no photography, the page
159
+ 2. **Bespoke drawn/procedural artifacts carry the missing media role.** With no photography, the page
160
160
  earns visual richness from things MADE for it: a live canvas (meter,
161
161
  field, trace), custom SVG structure (rules, notches, connectors, dials —
162
162
  drawn, not icon-font), procedural texture/grain, a data-driven ornament.
@@ -165,16 +165,14 @@ its own plan didn't describe. Hard rules for any expressive+ no-media build:
165
165
  3. **Type does structural work.** Multi-scale composition (oversized
166
166
  numerals/designators against small mono metadata), real hierarchy per §6
167
167
  — not one font-size row layout repeated N times.
168
- 4. **Motion budget spends on the structure**, not on entrance fades: the
169
- spine's own physicality (things racked, tossed, slid, stamped) is the
170
- choreography. Motion.md's inventory applies at full dial.
171
- 5. **A live rendered layer is required at award tier.** No imagery does not
172
- mean no rendering: the page ships at least one WebGL/canvas system doing
173
- real graphic work a particle field, procedural texture/flowfield,
174
- shader-lit surface, a data-driven instrument that never stops — plus
175
- dimensional motion somewhere real (CSS 3D card physicality, depth-layered
176
- parallax, a lit WebGL surface). A no-media award page with zero rendered
177
- pixels is a text document with transitions. Vocabulary to draw from
168
+ 4. **Motion budget spends on the structure**, not on entrance fades: the
169
+ spine's own physicality (things racked, tossed, slid, stamped) is the
170
+ choreography. Motion.md's treatment and verification rules apply.
171
+ 5. **Choose a rendering layer only when it earns its cost.** No imagery can
172
+ justify Canvas/WebGL when a live procedural or data-driven surface is the
173
+ concept, but award ambition does not require one. Spatial typography,
174
+ layered DOM/SVG composition, masks, or other subject-grounded systems may be
175
+ stronger. Vocabulary to draw from when rendering is warranted
178
176
  (subject-grounded, not decorative): oscilloscope/waveform traces driven by
179
177
  real events · topographic contour lines drifting · dot-matrix/LED field
180
178
  that spells live state · plexus line-network connecting related items ·
@@ -361,10 +359,17 @@ sites win on composition, not decoration; this is where that happens.
361
359
  - Product register: responsive behavior is structural (collapse sidebar, responsive
362
360
  table), not fluid type.
363
361
 
364
- ## 6. Motion
365
-
366
- Every animation answers "what does this communicate?" — hierarchy, storytelling,
367
- feedback, or state change. "It looked cool" = delete it.
362
+ ## 6. Motion
363
+
364
+ Every animation answers "what does this communicate?" — hierarchy, storytelling,
365
+ feedback, or state change. "It looked cool" = delete it.
366
+
367
+ Motion has three roles. **Decorative motion** reveals, responds, or adds ambient
368
+ life. **Structural motion** controls hierarchy, pacing, pinning, and composition
369
+ handoffs. **Transformational motion** changes imagery/type/objects into a new
370
+ form or the next scene. Expressive/award pages evaluate key moments for the last
371
+ two roles; opacity, translate, scale, and slight parallax are not automatically
372
+ enough merely because they animate.
368
373
 
369
374
  **The 100/300/500 rule:** 100-150ms instant feedback (press, toggle) · 200-300ms
370
375
  state changes (menu, tooltip, hover) · 300-500ms layout changes (accordion, modal)
@@ -381,9 +386,12 @@ micro-interactions; the uniform fade-and-rise on every scrolled section is the
381
386
  saturated AI tell. Some brands skip entrance motion entirely — restraint as voice.
382
387
  Product = 150-250ms, state-conveying only, zero page-load choreography.
383
388
 
384
- **Mechanics:** animate transform/opacity by default; blur/backdrop-filter/clip-path/
385
- masks/shadow allowed when they materially improve the effect, bounded to small areas,
386
- verified smooth. Never casually animate width/height/top/left/margins (use FLIP or
389
+ **Mechanics:** use transform/opacity for ordinary local state when they fit, but
390
+ do not make them the unexamined expressive default. Select CSS, GSAP timelines,
391
+ ScrollTrigger, SVG, masks, `clip-path`, DOM fragments, Canvas, WebGL/shaders,
392
+ sequences, video, Lottie, or 3D by visual result, cost, responsiveness, and
393
+ available assets. Use the simplest mechanism capable of making the intended
394
+ transformation convincing. Never casually animate width/height/top/left/margins (use FLIP or
387
395
  grid-template-rows). Sibling stagger `calc(var(--i) * 50ms)` capped at ~500ms total.
388
396
  IntersectionObserver (unobserve after firing), CSS scroll-driven animations, or
389
397
  Motion/GSAP scroll tools — `window.addEventListener("scroll")` is a hard ban. Reveals
@@ -734,11 +742,11 @@ On touch, whatever hover revealed must be visible by default or reachable by
734
742
  tap — never lost. Custom cursors simply don't exist on touch; don't ship their
735
743
  DOM/listeners there.
736
744
 
737
- **Pinned/scrubbed sections**: pinning is fragile on mobile (URL-bar resize
745
+ **Pinned/scrubbed sections**: pinning is fragile on mobile (URL-bar resize
738
746
  fires viewport changes mid-pin; iOS momentum scroll skips scrub frames).
739
- Strategies in preference order: (1) keep the pin but shorten it (≤ 1.5
740
- viewports) and drive it with `position: sticky`, never JS pinning; (2) unpin
741
- the same content as stacked sections with simple in-view reveals;
747
+ Strategies in preference order: (1) keep the pin but shorten it and drive it
748
+ with `position: sticky` when reliable; (2) redesign the same idea as a shorter
749
+ mask, layered-parallax, or state handoff sequence rather than merely disabling it;
742
750
  (3) `ScrollTrigger.matchMedia()` / a `matchMedia` branch that swaps the whole
743
751
  choreography. Never let a desktop pin length ride on a phone.
744
752
 
@@ -765,10 +773,10 @@ clamps DOWN gracefully (hero ≥ 2rem, body stays ≥ 16px — iOS zooms inputs
765
773
  under 16px); sticky navs shrink rather than stack; test the three widths 320 /
766
774
  768 / 1280 mentally before responding.
767
775
 
768
- **Motion budget on mobile**: entrance choreography total 600ms (vs 900ms
769
- desktop), at most 1 scroll-driven sequence per page, marquees pause when
770
- off-screen. Battery is a design constraint: continuous rAF loops must idle
771
- when nothing animates.
776
+ **Motion budget on mobile**: shorten travel and timelines according to content,
777
+ viewport, and measured device cost. Preserve one defining idea when feasible;
778
+ marquees pause off-screen and continuous rAF loops idle when nothing animates.
779
+ Battery is a design constraint, not a reason to erase the concept.
772
780
 
773
781
  ## 14. Typography sourcing (escape the first reflex)
774
782