dreative 0.5.2 → 0.5.4

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.
@@ -17,16 +17,6 @@ const AESTHETIC_DIALS = {
17
17
  "dark-tech": [6, 5, 5],
18
18
  trust: [3, 2, 5],
19
19
  };
20
- /** Layout families rotated across sections so no page repeats itself. */
21
- const FAMILIES = [
22
- "asymmetric-split",
23
- "bento-grid",
24
- "full-width-statement",
25
- "stacked-vertical",
26
- "offset-two-col",
27
- "horizontal-scroll-strip",
28
- "editorial-columns",
29
- ];
30
20
  function resolveDials(brief) {
31
21
  const base = AESTHETIC_DIALS[brief?.aesthetic ?? ""] ?? [7, 5, 4];
32
22
  return {
@@ -106,24 +96,36 @@ export function lintLayout(layout) {
106
96
  lints.push(`${buttons} buttons on one page — consolidate duplicate CTA intents (one label per intent)`);
107
97
  return lints;
108
98
  }
99
+ const STYLE_ONLY = /\b(colou?r|font|typograph|radius|shadow|gradient|animation|fade|slide|scale|hover|spacing|token)\b/i;
100
+ const STRUCTURAL_LANGUAGE = /\b(architecture|boundary|workflow|navigation|hierarchy|order|interaction|task|workspace|model|rebuild|recompose|replace|merge|move|remove)\b/i;
101
+ /** Blocking contract lints used by tests and agent-facing runtime plans. */
102
+ export function lintDesignPlan(plan) {
103
+ const lints = [];
104
+ const delta = plan.structuralDelta;
105
+ const deltaText = [delta.proposedModel, delta.proposedParadigm, delta.depthHonesty, ...delta.materialChanges].join(" ");
106
+ if ((plan.depth === "restructure" || plan.depth === "reimagine") && (!STRUCTURAL_LANGUAGE.test(deltaText) || (STYLE_ONLY.test(deltaText) && !STRUCTURAL_LANGUAGE.test(delta.materialChanges.join(" ")))))
107
+ lints.push(`${plan.depth} requires a concrete architectural or interaction-model delta; stylesheet and generic motion changes cannot satisfy it`);
108
+ if (plan.depth !== "restyle" && delta.materialChanges.length < 2)
109
+ lints.push(`${plan.depth} requires multiple concrete changes to composition, hierarchy, architecture, or interaction`);
110
+ const mobileText = [plan.mobileBlueprint.mobileOnlyComposition, plan.mobileBlueprint.composition390, plan.mobileBlueprint.fallback320, plan.mobileBlueprint.stackingRejection].join(" ");
111
+ if (/^\s*stack(?:ed)?(?:\s+vertically)?\s*$/i.test(plan.mobileBlueprint.mobileOnlyComposition) || !/\b(order|disclosure|navigation|task|action|media|thumb|viewport|recompose|context)\b/i.test(mobileText))
112
+ lints.push("mobile blueprint must define a mobile-native composition; ‘stack vertically’ alone is blocking");
113
+ if ((plan.tier === "expressive" || plan.tier === "award") && !plan.expression)
114
+ lints.push(`${plan.tier} requires a project-specific expression contract or a documented intentional-calm exception in the direct plan`);
115
+ if (plan.expression && /\b(fade|slide|scale|hover|gradient|large typography)\b/i.test(plan.expression.mechanism) && !/\b(state|selection|progress|navigation|transform|content|spatial)\b/i.test(plan.expression.communicates))
116
+ lints.push("expression mechanism is decorative-only and does not communicate content, state, progression, selection, navigation, or transformation");
117
+ return lints;
118
+ }
109
119
  /** Build the compact, executable plan sent to the agent with design-page. */
110
120
  export function buildDesignPlan(page, brief, assignedSkills) {
111
121
  const dials = resolveDials(brief);
112
122
  const aesthetic = brief?.aesthetic || "auto (infer per DESIGN.md, then commit)";
113
123
  const allowedSkills = assignedSkills ? new Set(resolveSkillDependencies(assignedSkills)) : undefined;
114
- const sections = (page.layout.children ?? []).map((s, i) => {
115
- let family;
116
- if (s.type === "nav")
117
- family = "nav-single-line";
118
- else if (s.type === "hero")
119
- family = dials.variance > 4 ? "asymmetric-split-hero" : "centered-hero";
120
- else if (s.type === "footer")
121
- family = "footer";
122
- else
123
- family = FAMILIES[i % FAMILIES.length];
124
+ const sections = (page.layout.children ?? []).map((s) => {
124
125
  const detected = resolveSkillDependencies(detectSpecialistSkills(blockTexts(s)));
125
126
  const secSkills = allowedSkills ? detected.filter((skill) => allowedSkills.has(skill)) : detected;
126
- return secSkills.length ? { id: s.id, label: s.label, family, skills: secSkills } : { id: s.id, label: s.label, family };
127
+ const role = s.type === "nav" ? "navigation" : s.type === "footer" ? "closure" : s.type === "form" ? "task-input" : s.type === "hero" ? "orientation-or-promotion" : "content-or-task-support";
128
+ return secSkills.length ? { id: s.id, label: s.label, role, skills: secSkills } : { id: s.id, label: s.label, role };
127
129
  });
128
130
  // page-level specialist skills: brief/prompt keywords + section hits + motion dial
129
131
  const requestedSkills = new Set([
@@ -138,18 +140,27 @@ export function buildDesignPlan(page, brief, assignedSkills) {
138
140
  aesthetic,
139
141
  texts: [brief?.vibe, brief?.notes, page.designPrompt, ...blockTexts(page.layout)],
140
142
  });
143
+ const depth = depthFor(brief, page);
144
+ const register = inferRegister(page);
145
+ const delta = structuralDelta(page, depth, register);
146
+ const mobile = mobileBlueprint(page, register);
147
+ const expression = expressionContract(page, tier, register);
141
148
  const spacing = dials.density <= 3 ? "py-24/py-32 section gaps" : dials.density <= 6 ? "py-16/py-20" : "py-8/py-12, hairline dividers, mono numerals";
142
149
  const motionBudget = dials.motion <= 3
143
150
  ? "hover/active states only, no scroll animation"
144
151
  : 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";
152
+ ? "purposeful local motion; use structural transitions only where content changes state, with visible defaults"
153
+ : "write section motion treatments; concentrate structural/transformational choreography into a few hero moments with calm sections, mobile translations, and reduced-motion states";
147
154
  const directives = [
148
155
  `aesthetic: ${aesthetic}; dials variance=${dials.variance} motion=${dials.motion} density=${dials.density}`,
149
156
  `spacing scale: ${spacing}`,
150
157
  `motion budget: ${motionBudget}`,
158
+ `transformation depth: ${depth}; source strategy: ${sourceStrategyFor(depth)}; ambition remains independently ${tier}`,
159
+ `page register: ${register}; compose for this page's task model rather than inheriting a shared marketing shell`,
160
+ "choose the simplest mechanism that makes each intended transformation convincing; do not default blindly to fade/slide/scale or to WebGL/3D",
151
161
  "one accent color, one neutral family, one radius system, locked across ALL pages of this project",
152
- "section layout families are assigned below follow them, do not default to repeated card rows",
162
+ "section entries describe existing semantic roles only; author the proposed composition from the structural delta instead of cycling through layout families",
163
+ "preserve routes, handlers, data flow, fields, states, accessibility, analytics, required copy, and public APIs; do not preserve DOM hierarchy, card shells, section order, desktop composition, visual grouping, or motion by default",
153
164
  ...(dials.variance > 4 ? ["avoid centered-hero-over-gradient; use split or asymmetric composition"] : []),
154
165
  ...(skills.length
155
166
  ? [
@@ -158,17 +169,191 @@ export function buildDesignPlan(page, brief, assignedSkills) {
158
169
  : []),
159
170
  ];
160
171
  return {
161
- version: 1,
172
+ version: 2,
162
173
  dials,
163
174
  aesthetic,
164
175
  tier,
176
+ depth,
177
+ sourceStrategy: sourceStrategyFor(depth),
178
+ register,
179
+ structuralDelta: delta,
180
+ mobileBlueprint: mobile,
181
+ ...(expression ? { expression } : {}),
165
182
  sections,
166
183
  directives,
167
- lints: lintLayout(page.layout),
184
+ lints: [...lintLayout(page.layout), ...lintDesignPlan({ depth, structuralDelta: delta, mobileBlueprint: mobile, tier, expression })],
168
185
  skills,
169
186
  verification: TIER_REQUIREMENTS[tier],
170
187
  };
171
188
  }
189
+ function depthFor(brief, page) {
190
+ if (brief?.transformationDepth)
191
+ return brief.transformationDepth;
192
+ const text = `${page.designPrompt ?? ""} ${brief?.notes ?? ""}`;
193
+ if (/\breimagine\b/i.test(text))
194
+ return "reimagine";
195
+ if (/\brestructure\b|\brebuild\b/i.test(text))
196
+ return "restructure";
197
+ if (/\brelayout\b|\brecompose\b/i.test(text))
198
+ return "relayout";
199
+ return "restyle";
200
+ }
201
+ export function sourceStrategyFor(depth) {
202
+ if (depth === "restyle")
203
+ return "patch";
204
+ if (depth === "relayout")
205
+ return "recompose";
206
+ return "rebuild-from-contracts";
207
+ }
208
+ export function buildDesignSourceContext(depth, generatedFile) {
209
+ const strategy = sourceStrategyFor(depth);
210
+ if (strategy === "patch") {
211
+ return { strategy, previousFile: generatedFile, compositionDirective: "Patch the existing composition while preserving its markup and interaction architecture." };
212
+ }
213
+ if (strategy === "recompose") {
214
+ return {
215
+ strategy,
216
+ behaviorReferenceFile: generatedFile,
217
+ compositionDirective: "Use the existing file for behavior and content inventory, but recompose ordering, hierarchy, and layout before styling.",
218
+ };
219
+ }
220
+ return {
221
+ strategy,
222
+ behaviorReferenceFile: generatedFile,
223
+ compositionDirective: "Create the blueprint independently from the previous visual tree. Read the reference only for routes, handlers, data flow, fields, states, accessibility, analytics, copy, and public APIs; rebuild markup and component boundaries from the approved contracts.",
224
+ };
225
+ }
226
+ function inferRegister(page) {
227
+ const text = [page.name, page.designPrompt, ...blockTexts(page.layout)].join(" ").toLowerCase();
228
+ if (/\b(login|sign in|sign up|password|authentication)\b/.test(text))
229
+ return "authentication";
230
+ if (/\b(admin|moderation|manage users|cms)\b/.test(text))
231
+ return "administration";
232
+ if (/\b(account|profile|order status|billing|settings)\b/.test(text))
233
+ return "account-status";
234
+ if (/\b(search|select|checkout|order|booking|form|continue|payment|outlet)\b/.test(text))
235
+ return "task-transaction";
236
+ if (/\b(error|empty|loading|not found)\b/.test(text))
237
+ return "system-state";
238
+ if (/\b(dashboard|table|analytics|report|data)\b/.test(text))
239
+ return "data-dense-utility";
240
+ if (/\b(catalog|browse|collection|discover|filter)\b/.test(text))
241
+ return "discovery-browse";
242
+ return "marketing-storytelling";
243
+ }
244
+ function describeExisting(page) {
245
+ const sections = (page.layout.children ?? []).map((section) => section.label).filter(Boolean);
246
+ return sections.length ? sections.join(" followed by ") : page.layout.label;
247
+ }
248
+ function taskFor(page) {
249
+ const form = (page.layout.children ?? []).find((section) => section.type === "form");
250
+ const action = blockTexts(page.layout).find((text) => /\b(select|continue|submit|search|choose|buy|book|save|sign in)\b/i.test(text));
251
+ return action ?? form?.label ?? `complete ${page.name}`;
252
+ }
253
+ function structuralDelta(page, depth, register) {
254
+ const existing = describeExisting(page);
255
+ const task = taskFor(page);
256
+ if (depth === "restyle") {
257
+ return {
258
+ existingModel: existing,
259
+ proposedModel: `${existing}, retained intentionally with a newly authored visual and state language`,
260
+ existingParadigm: "Existing DOM order, component boundaries, and interaction flow",
261
+ proposedParadigm: "The same interaction architecture with a coherent visual, type, surface, and authored-state treatment",
262
+ materialChanges: ["Restyle tokens, typography, surfaces, responsive polish, and state presentation without claiming structural transformation"],
263
+ survivingBoundaries: (page.layout.children ?? []).map((section) => section.label),
264
+ rebuiltBoundaries: [],
265
+ preservedContracts: ["Routes, handlers, data flow, field names, states, accessibility, analytics, copy, and public APIs"],
266
+ retainedPatterns: [{ pattern: "Existing section and task order", rationale: "Restyle explicitly permits structural continuity." }],
267
+ forbiddenCarryovers: ["Unexamined accessibility defects", "Decorative motion presented as structural change"],
268
+ depthHonesty: "This is a restyle: structure is retained and the plan makes no relayout, restructure, or reimagine claim.",
269
+ };
270
+ }
271
+ const proposedModel = register === "task-transaction"
272
+ ? `A task-first ${page.name} workspace organized around “${task}”, with supporting context revealed at the point of decision`
273
+ : depth === "reimagine"
274
+ ? `A new ${page.name} experience model organized around its ${register.replaceAll("-", " ")} purpose rather than the inherited section sequence`
275
+ : `A recomposed ${page.name} hierarchy organized by user intent rather than the inherited ${existing}`;
276
+ const deep = depth === "restructure" || depth === "reimagine";
277
+ return {
278
+ existingModel: existing,
279
+ proposedModel,
280
+ existingParadigm: `Linear inherited composition: ${existing}`,
281
+ proposedParadigm: register === "task-transaction" ? "Task-first contextual workspace with action and decision state in one flow" : "Intent-led page composition with page-specific hierarchy",
282
+ materialChanges: deep
283
+ ? [
284
+ `Rebuild the page architecture around “${task}” instead of preserving the inherited section tree`,
285
+ "Move supporting or promotional content after the primary task unless it is required for the decision",
286
+ "Reconcile preserved behavior only after the new component boundaries exist",
287
+ ]
288
+ : ["Change section order and hierarchy", "Replace the inherited layout family with an intent-led composition", "Translate desktop grouping deliberately for mobile"],
289
+ survivingBoundaries: ["Behavior, data, route, and accessibility contracts"],
290
+ rebuiltBoundaries: deep ? (page.layout.children ?? []).filter((section) => !["nav", "footer"].includes(section.type)).map((section) => section.label) : ["Page composition boundary"],
291
+ preservedContracts: ["Routes, handlers, data flow, field names, states, accessibility, analytics, required copy, and public APIs"],
292
+ retainedPatterns: [{ pattern: "Semantic task controls", rationale: "Their behavior is contractual even when their visual grouping and DOM position change." }],
293
+ forbiddenCarryovers: ["Inherited DOM hierarchy as the composition source", "Card shells and section order without page-specific rationale", "Desktop grouping copied directly to mobile"],
294
+ depthHonesty: deep
295
+ ? `${depth} changes page architecture and component boundaries; token, font, color, radius, shadow, and entrance-animation changes cannot satisfy it.`
296
+ : "Relayout changes ordering, hierarchy, and layout family while preserving the underlying workflow and most component responsibilities.",
297
+ };
298
+ }
299
+ function mobileBlueprint(page, register) {
300
+ const task = taskFor(page);
301
+ const taskFirst = register === "task-transaction" || register === "authentication" || register === "data-dense-utility";
302
+ return {
303
+ primaryTask: task,
304
+ firstViewportPurpose: taskFirst ? `Let the user begin “${task}” immediately, before promotional or decorative material` : `Establish the ${page.name} purpose and expose its primary next action`,
305
+ contentOrder: taskFirst ? ["compact navigation/context", `controls and information required to ${task}`, "current state or result", "supporting explanation", "promotional or decorative material"] : ["identity and purpose", "primary action or navigation", "core content", "supporting content"],
306
+ beforeFirstScroll: ["Page identity", `The control or information needed to ${task}`, "Primary action or current selection state"],
307
+ primaryThumbAction: { action: task, placement: "Within the lower-thumb reach zone; sticky only when it does not cover content or duplicate navigation" },
308
+ stickyElements: ["Only task-critical action or compact navigation; never duplicate the same CTA"],
309
+ safeArea: "Inset fixed controls with env(safe-area-inset-bottom) and reserve matching content padding",
310
+ navigationModel: "Compact mobile navigation appropriate to this page register, with no duplicate desktop navigation row",
311
+ mobileOnlyComposition: taskFirst ? "Collapse supporting context into contextual disclosure while keeping task controls and state in one continuous decision surface" : "Use mobile pacing and disclosure designed around reading and thumb reach, not desktop columns",
312
+ desktopTranslation: { retained: ["Required functionality and semantic content"], translated: ["Spatial grouping, navigation, media, and state transitions"], removed: ["Nonessential decorative blockers before the task"], replaced: ["Hover-only affordances with visible tap/focus controls"] },
313
+ mediaStrategy: "Use task-supporting media at mobile scale; defer or remove decorative media that delays the primary task",
314
+ motionStrategy: "Translate authored state changes to short, interruptible mobile transitions; preserve visible defaults and reduced motion",
315
+ keyboardAndForms: "Keep focused fields and the primary action visible above the software keyboard; use appropriate input modes and no focus traps",
316
+ composition390: `At 390×844, ${taskFirst ? "the task begins in the first viewport and its action remains reachable" : "identity, purpose, and primary navigation form a complete first viewport"}`,
317
+ fallback320: "At 320px, remove nonessential decoration, allow labels to wrap, keep controls within the viewport, and avoid fixed minimum typography or widths",
318
+ stackingRejection: "The mobile content order, disclosure, navigation, task action, and media treatment are explicitly recomposed; desktop columns are not merely stacked.",
319
+ verificationChecks: [
320
+ "no-horizontal-overflow", "no-clipped-content", "fixed-elements-clear-content", "safe-area-spacing",
321
+ "primary-task-discoverable", "primary-action-reachable", "touch-targets", "software-keyboard-usable",
322
+ "no-hover-only", "intentional-mobile-composition", "mobile-content-order", "motion-media-translated",
323
+ "no-decorative-task-blocker",
324
+ ],
325
+ };
326
+ }
327
+ function expressionContract(page, tier, register) {
328
+ if (tier !== "expressive" && tier !== "award")
329
+ return undefined;
330
+ const task = taskFor(page);
331
+ return {
332
+ mechanism: register === "task-transaction" ? `The user’s current “${task}” selection becomes the persistent context for the next task state` : `${page.name} navigation changes form to reflect the user’s current content or progression state`,
333
+ communicates: register === "task-transaction" ? "Selection, operational state, and readiness to continue" : "Position, progression, and relationship between content states",
334
+ projectFit: `The behavior is derived from ${page.name} and its ${register.replaceAll("-", " ")} role, rather than a reusable reveal effect.`,
335
+ location: `The primary ${page.name} task and its transition to the next meaningful state`,
336
+ mobileTranslation: "Keep the state-carrying behavior, shorten travel, and place feedback beside the thumb action",
337
+ reducedMotion: "Render the communicated state directly with a short cross-state update and no spatial travel",
338
+ fallback: "Semantic state text and conventional navigation preserve the complete task without animation",
339
+ verification: "Verify that content or application state—not only opacity, translate, scale, color, or decoration—changes and remains understandable on mobile and reduced motion",
340
+ };
341
+ }
342
+ export function buildRuntimeCoherence(pages, brief) {
343
+ return {
344
+ globalVisualLanguage: "Share tokens, type roles, material logic, and state markers without sharing one page composition.",
345
+ globalInteractionLanguage: "Use consistent control feedback and continuity; let each page register define navigation, density, and task flow.",
346
+ pages: pages.map((page) => {
347
+ const register = inferRegister(page);
348
+ return { pageId: page.id, name: page.name, register, primaryTask: taskFor(page) };
349
+ }),
350
+ prohibitedRepeatedShells: [
351
+ "Oversized headline plus decorative image panel plus rounded card container on unrelated routes",
352
+ "The same layout family assigned by page or section array position",
353
+ "Pages differing only by headline, color, and image",
354
+ ],
355
+ };
356
+ }
172
357
  /** Build page plans from a user-approved pool. Unselected optional skills are
173
358
  * suggestions only; explicit page assignments win. */
174
359
  export function buildMultiPageDesignPlans(pages, brief, selection) {
@@ -181,6 +366,7 @@ export function buildMultiPageDesignPlans(pages, brief, selection) {
181
366
  });
182
367
  return {
183
368
  routing,
369
+ coherence: buildRuntimeCoherence(pages, brief),
184
370
  pages: pages.map((page) => ({ pageId: page.id, plan: buildDesignPlan(page, brief, routing.byPage[page.id]) })),
185
371
  };
186
372
  }
@@ -0,0 +1,70 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildDesignPlan, buildDesignSourceContext, buildRuntimeCoherence, lintDesignPlan } from "./design.js";
4
+ function transactionalPage() {
5
+ const section = (id, type, label, children = []) => ({ id, type, label, direction: "column", children });
6
+ return {
7
+ id: "outlet-selection",
8
+ name: "Choose an outlet",
9
+ status: "designed",
10
+ canvasPos: { x: 0, y: 0 },
11
+ generatedFile: "generated/outlet-selection.tsx",
12
+ layout: section("root", "column", "Outlet selection page", [
13
+ section("header", "nav", "Standard header"),
14
+ section("intro", "hero", "Promotional chicken-rice introduction"),
15
+ section("search", "form", "Search outlets", [{ id: "search-button", type: "button", label: "Search nearby outlets", text: "Search" }]),
16
+ section("cards", "card-grid", "Repeated outlet cards"),
17
+ section("continue", "section", "Continuation action", [{ id: "continue-button", type: "button", label: "Continue with selected outlet", text: "Continue" }]),
18
+ ]),
19
+ };
20
+ }
21
+ function plan(brief) {
22
+ return buildDesignPlan(transactionalPage(), brief);
23
+ }
24
+ test("case A: restyle + expressive retains structure without claiming transformation", () => {
25
+ const result = plan({ transformationDepth: "restyle", variance: 8, motion: 8 });
26
+ assert.equal(result.depth, "restyle");
27
+ assert.equal(result.tier, "expressive");
28
+ assert.equal(result.sourceStrategy, "patch");
29
+ assert.equal(result.structuralDelta.rebuiltBoundaries.length, 0);
30
+ assert.match(result.structuralDelta.depthHonesty, /restyle/i);
31
+ });
32
+ test("case B: restructure + solid produces a task-specific architecture", () => {
33
+ const result = plan({ transformationDepth: "restructure", aesthetic: "trust", variance: 3, motion: 2 });
34
+ assert.equal(result.tier, "solid");
35
+ assert.equal(result.depth, "restructure");
36
+ assert.equal(result.register, "task-transaction");
37
+ assert.match(result.structuralDelta.proposedModel, /task-first.*workspace/i);
38
+ assert.ok(result.structuralDelta.materialChanges.length >= 3);
39
+ assert.equal(lintDesignPlan(result).length, 0);
40
+ });
41
+ test("case C: restructure + expressive carries structural, expression, mobile, preservation, and verification directives", () => {
42
+ const result = plan({ transformationDepth: "restructure", variance: 8, motion: 8 });
43
+ assert.equal(result.sourceStrategy, "rebuild-from-contracts");
44
+ assert.ok(result.expression);
45
+ assert.match(result.expression.mechanism, /selection.*persistent context/i);
46
+ assert.match(result.mobileBlueprint.firstViewportPurpose, /immediately.*before promotional/i);
47
+ assert.match(result.structuralDelta.preservedContracts.join(" "), /routes.*handlers.*data flow/i);
48
+ assert.ok(result.verification.length > 0);
49
+ });
50
+ test("case D: stack-only mobile plan is blocking", () => {
51
+ const result = plan({ transformationDepth: "restructure", variance: 3, motion: 2 });
52
+ result.mobileBlueprint.mobileOnlyComposition = "stack vertically";
53
+ assert.ok(lintDesignPlan(result).some((message) => message.includes("mobile-native")));
54
+ });
55
+ test("case E: deep redesign treats previous file as behavior reference, never composition source", () => {
56
+ const deep = buildDesignSourceContext("restructure", "generated/outlet-selection.tsx");
57
+ assert.equal(deep.strategy, "rebuild-from-contracts");
58
+ assert.equal(deep.previousFile, undefined);
59
+ assert.equal(deep.behaviorReferenceFile, "generated/outlet-selection.tsx");
60
+ assert.match(deep.compositionDirective, /independently from the previous visual tree/i);
61
+ const light = buildDesignSourceContext("restyle", "generated/outlet-selection.tsx");
62
+ assert.equal(light.previousFile, "generated/outlet-selection.tsx");
63
+ });
64
+ test("multi-page coherence records page registers without assigning a repeated shell", () => {
65
+ const outlet = transactionalPage();
66
+ const account = { ...transactionalPage(), id: "account", name: "Account status", designPrompt: "Profile and billing settings" };
67
+ const coherence = buildRuntimeCoherence([outlet, account], { transformationDepth: "restructure" });
68
+ assert.deepEqual(coherence.pages.map((item) => item.register), ["task-transaction", "account-status"]);
69
+ assert.ok(coherence.prohibitedRepeatedShells.some((item) => item.includes("unrelated routes")));
70
+ });
@@ -9,8 +9,14 @@ 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
- if (plan.doctrineVersion !== 2)
19
+ if (plan.doctrineVersion !== 2 && plan.doctrineVersion !== 3)
14
20
  return [];
15
21
  const errors = [];
16
22
  const rules = new Map(registry.rules.map((rule) => [rule.id, rule]));
@@ -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")) {