dreative 0.5.3 → 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.
package/dist/cli/audit.js CHANGED
@@ -77,6 +77,38 @@ function checkAssets(projectDir, plan) {
77
77
  }
78
78
  return findings;
79
79
  }
80
+ function checkAntiSlopPlan(plan) {
81
+ if (plan.version !== 3)
82
+ return [];
83
+ const findings = [];
84
+ const signatures = new Map();
85
+ for (const page of plan.pages) {
86
+ const signature = page.sections.map((section) => section.layoutFamily.trim().toLowerCase()).join(" > ");
87
+ signatures.set(signature, [...(signatures.get(signature) ?? []), page.name]);
88
+ const cardSections = page.sections.filter((section) => /\b(card|tile)\b/i.test(section.layoutFamily));
89
+ if (cardSections.length > 2)
90
+ findings.push(finding("warning", "anti-slop", `${page.name}: repeated card-family sections need an explicit page-specific rationale`));
91
+ if (page.sections.some((section) => /^\s*stack(?:ed)?(?:\s+vertically)?[.!]?\s*$/i.test(section.mobile)))
92
+ findings.push(finding("error", "anti-slop", `${page.name}: a section uses stack-only mobile translation`));
93
+ if (page.register === "task-transaction") {
94
+ const first = page.mobileBlueprint?.contentOrder?.[0] ?? "";
95
+ if (/\b(promo|decorative|hero|story)\b/i.test(first))
96
+ findings.push(finding("error", "anti-slop", `${page.name}: promotional or decorative content precedes the primary mobile task`));
97
+ }
98
+ for (const section of page.sections) {
99
+ if (/\b(testimonial|review)\b/i.test(section.name) && !/\b(state|task|concept|product|brand|evidence|decision)\b/i.test([section.layoutFamily, ...section.interactions].join(" ")))
100
+ findings.push(finding("warning", "anti-slop", `${page.name}/${section.name}: generic social-proof section appears detached from the page concept`));
101
+ }
102
+ }
103
+ for (const [signature, pages] of signatures) {
104
+ if (signature && pages.length > 1)
105
+ findings.push(finding("warning", "anti-slop", `${pages.join(", ")}: unrelated routes repeat the same section-layout shell (${signature})`));
106
+ }
107
+ const compositions = plan.coherence?.pageSpecificCompositions ?? [];
108
+ if (compositions.length > 1 && new Set(compositions.map((item) => `${item.register}:${item.taskModel.trim().toLowerCase()}`)).size === 1)
109
+ findings.push(finding("warning", "anti-slop", "all pages were assigned the same register and task model; verify that composition was not copied mechanically"));
110
+ return findings;
111
+ }
80
112
  const SOURCE_EXTENSIONS = new Set([".tsx", ".jsx", ".vue", ".svelte", ".html", ".css", ".scss"]);
81
113
  const SKIP_DIRS = new Set([".git", ".dreative", "node_modules", "dist", "build", ".next", ".nuxt"]);
82
114
  function collectSourceFiles(root, current = root) {
@@ -151,6 +183,46 @@ function checkVerificationProof(projectDir, verificationFile) {
151
183
  }
152
184
  return findings;
153
185
  }
186
+ export function checkVerificationCoverage(plan, report) {
187
+ const findings = [];
188
+ if (plan.version !== 3)
189
+ return findings;
190
+ if (report.version !== 2)
191
+ return [finding("error", "migration", "v3 plans require verify.json version 2; legacy verification cannot satisfy depth and mobile guarantees")];
192
+ const passing = report.evidence.filter((item) => item.status === "pass");
193
+ for (const page of plan.pages) {
194
+ for (const viewportClass of ["desktop", "mobile", "narrow-mobile"]) {
195
+ if (!passing.some((item) => item.pageId === page.id && item.viewportClass === viewportClass && item.proof?.viewport))
196
+ findings.push(finding("error", "verification", `${page.name}: missing passing ${viewportClass} evidence with an exact viewport`));
197
+ }
198
+ if ((plan.depth === "restructure" || plan.depth === "reimagine") && !passing.some((item) => item.pageId === page.id && item.kind === "structural-depth"))
199
+ findings.push(finding("error", "verification", `${page.name}: missing structural-depth evidence against its structural delta`));
200
+ if (!passing.some((item) => item.pageId === page.id && item.kind === "preservation"))
201
+ findings.push(finding("error", "verification", `${page.name}: missing preservation evidence`));
202
+ for (const check of page.mobileBlueprint?.verificationChecks ?? []) {
203
+ if (!passing.some((item) => item.pageId === page.id && (item.viewportClass === "mobile" || item.viewportClass === "narrow-mobile") && item.mobileChecks?.includes(check)))
204
+ findings.push(finding("error", "verification", `${page.name}: missing mobile evidence for ${check}`));
205
+ }
206
+ for (const section of page.sections) {
207
+ for (const criterion of section.verification) {
208
+ if (typeof criterion === "string") {
209
+ findings.push(finding("error", "verification", `${page.name}/${section.name}: v3 criteria must be typed objects, not strings`));
210
+ continue;
211
+ }
212
+ for (const viewport of criterion.viewports) {
213
+ const match = passing.find((item) => item.criterionId === criterion.id &&
214
+ item.pageId === criterion.pageId &&
215
+ item.sectionId === criterion.sectionId &&
216
+ item.kind === criterion.kind &&
217
+ item.viewportClass === viewport);
218
+ if (!match)
219
+ findings.push(finding("error", "verification", `${page.name}/${section.name}: no passing ${viewport} evidence is associated with criterion ${criterion.id}`));
220
+ }
221
+ }
222
+ }
223
+ }
224
+ return findings;
225
+ }
154
226
  export function runDirectDesignAudit(projectDir) {
155
227
  const root = path.join(projectDir, ".dreative");
156
228
  const planFile = path.join(root, "plan.json");
@@ -165,13 +237,13 @@ export function runDirectDesignAudit(projectDir) {
165
237
  findings.push(...checkArtifact(ledgerFile, "ledger", validateDecisionLedger));
166
238
  findings.push(...checkArtifact(verificationFile, "verification", validateVerificationReport));
167
239
  findings.push(...checkVerificationProof(projectDir, verificationFile));
168
- if (plan.doctrineVersion !== 2) {
169
- findings.push(finding("warning", "migration", "legacy v2 plan accepted; add doctrineVersion: 2 and the new creative-control fields on the next design run"));
240
+ const verification = fs.existsSync(verificationFile) ? readJson(verificationFile) : undefined;
241
+ if (plan.version !== 3 || plan.doctrineVersion !== 3) {
242
+ findings.push(finding("warning", "migration", "legacy v2 plan accepted for compatibility only; migrate to plan v3 and verify v2 for structural-depth, mobile-blueprint, expression, and evidence-association guarantees"));
170
243
  }
171
244
  else {
172
245
  try {
173
246
  const { registry, reflexFonts } = loadRuleFiles();
174
- const verification = fs.existsSync(verificationFile) ? readJson(verificationFile) : undefined;
175
247
  for (const message of validateRuleControls(plan, registry, reflexFonts, verification))
176
248
  findings.push(finding("error", "rules", message));
177
249
  }
@@ -181,15 +253,19 @@ export function runDirectDesignAudit(projectDir) {
181
253
  }
182
254
  findings.push(...checkSkillClosure(plan));
183
255
  findings.push(...checkAssets(projectDir, plan));
256
+ findings.push(...checkAntiSlopPlan(plan));
184
257
  findings.push(...checkStaticQuality(projectDir, plan));
185
- const verify = fs.existsSync(verificationFile) ? JSON.stringify(readJson(verificationFile)) : "";
186
- for (const page of plan.pages) {
187
- for (const section of page.sections) {
188
- for (const criterion of section.verification) {
189
- if (!verify.includes(criterion))
190
- findings.push(finding("warning", "verification", `${page.name}/${section.name}: no evidence references criterion: ${criterion}`));
191
- }
192
- }
258
+ if (verification)
259
+ findings.push(...checkVerificationCoverage(plan, verification));
260
+ if (plan.version === 2) {
261
+ const verifyText = verification ? JSON.stringify(verification) : "";
262
+ for (const page of plan.pages)
263
+ for (const section of page.sections)
264
+ for (const criterion of section.verification) {
265
+ const text = typeof criterion === "string" ? criterion : criterion.claim;
266
+ if (!verifyText.includes(text))
267
+ findings.push(finding("warning", "verification", `${page.name}/${section.name}: no legacy evidence references criterion: ${text}`));
268
+ }
193
269
  }
194
270
  return { ok: !findings.some((item) => item.level === "error"), findings };
195
271
  }
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { runDirectDesignAudit } from "./audit.js";
6
+ import { checkVerificationCoverage, runDirectDesignAudit } from "./audit.js";
7
7
  test("direct-design audit verifies artifacts and preservation needles", () => {
8
8
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "dreative-audit-"));
9
9
  try {
@@ -77,3 +77,42 @@ test("direct-design audit verifies artifacts and preservation needles", () => {
77
77
  fs.rmSync(root, { recursive: true, force: true });
78
78
  }
79
79
  });
80
+ test("v3 audit associates criteria by id, page, section, kind, and viewport", () => {
81
+ const criterion = { id: "depth", claim: "Architecture matches delta", kind: "structural-depth", pageId: "outlets", sectionId: "workspace", viewports: ["desktop"] };
82
+ const plan = {
83
+ version: 3,
84
+ doctrineVersion: 3,
85
+ depth: "restructure",
86
+ pages: [{ id: "outlets", name: "Outlets", mobileBlueprint: { verificationChecks: ["no-horizontal-overflow"] }, sections: [{ id: "workspace", name: "Workspace", verification: [criterion] }] }],
87
+ };
88
+ const makeEvidence = (id, kind, viewportClass) => ({
89
+ id,
90
+ criterion: id,
91
+ criterionId: id === "depth-proof" ? "depth" : id,
92
+ pageId: "outlets",
93
+ sectionId: id === "depth-proof" ? "workspace" : undefined,
94
+ kind,
95
+ viewportClass,
96
+ mobileChecks: viewportClass === "desktop" ? undefined : ["no-horizontal-overflow"],
97
+ status: "pass",
98
+ evidence: "Browser and test evidence",
99
+ proof: { timestamp: new Date().toISOString(), viewport: { width: viewportClass === "desktop" ? 1280 : viewportClass === "mobile" ? 390 : 320, height: 844 }, artifactPath: `.dreative/${id}.png` },
100
+ });
101
+ const report = {
102
+ version: 2,
103
+ generatedAt: new Date().toISOString(),
104
+ evidence: [
105
+ makeEvidence("desktop", "responsive", "desktop"),
106
+ makeEvidence("mobile", "responsive", "mobile"),
107
+ makeEvidence("narrow", "responsive", "narrow-mobile"),
108
+ makeEvidence("preserved", "preservation", "desktop"),
109
+ makeEvidence("depth-proof", "structural-depth", "desktop"),
110
+ ],
111
+ };
112
+ assert.deepEqual(checkVerificationCoverage(plan, report), []);
113
+ plan.pages[0].mobileBlueprint.verificationChecks.push("touch-targets");
114
+ assert.ok(checkVerificationCoverage(plan, report).some((item) => item.message.includes("touch-targets")));
115
+ plan.pages[0].mobileBlueprint.verificationChecks.pop();
116
+ report.evidence[4].sectionId = "wrong-section";
117
+ assert.ok(checkVerificationCoverage(plan, report).some((item) => item.message.includes("criterion depth")));
118
+ });
@@ -6,7 +6,7 @@ import { Store, findBlock, replaceBlock, newId } from "./store.js";
6
6
  import { requestAgent, respond, nextEvent, pushFinish } from "./agentQueue.js";
7
7
  import { computeDiff } from "./diff.js";
8
8
  import { buildPreview, previewHtml, replicaHtml } from "./preview.js";
9
- import { buildDesignPlan } from "../shared/design.js";
9
+ import { buildDesignPlan, buildDesignSourceContext, buildRuntimeCoherence } from "../shared/design.js";
10
10
  import { startJob, getJob } from "./jobs.js";
11
11
  const here = path.dirname(fileURLToPath(import.meta.url));
12
12
  function cloneWithNewIds(block) {
@@ -180,6 +180,7 @@ export function createServer(projectDir) {
180
180
  if (!page)
181
181
  throw new Error("page not found");
182
182
  const siblingPages = project.pages.filter((p) => p.id !== pageId).map((p) => p.name);
183
+ const projectComposition = buildRuntimeCoherence(project.pages, project.brief);
183
184
  const blockRefs = [];
184
185
  const collect = (b) => {
185
186
  if (b.refImage)
@@ -188,18 +189,22 @@ export function createServer(projectDir) {
188
189
  };
189
190
  collect(page.layout);
190
191
  const outFile = `generated/${pageId}.tsx`;
192
+ const plan = buildDesignPlan(page, project.brief);
193
+ const source = buildDesignSourceContext(plan.depth, page.generatedFile);
191
194
  await requestAgent("design-page", {
192
195
  pageName: page.name,
193
196
  layout: page.layout,
194
197
  brief: project.brief,
195
- // Dreative decides, the agent executes: resolved dials, per-section
196
- // layout families, spacing/motion budgets, doctrine lints.
197
- plan: buildDesignPlan(page, project.brief),
198
+ // Dreative resolves depth, source strategy, contracts, budgets, and
199
+ // blocking lints; the agent authors the page-specific composition.
200
+ plan,
198
201
  refImage: page.refImage,
199
202
  blockRefs,
200
203
  designPrompt: designPrompt ?? page.designPrompt,
201
- previousFile: page.generatedFile,
204
+ source,
205
+ ...(source.previousFile ? { previousFile: source.previousFile } : {}),
202
206
  siblingPages,
207
+ projectComposition,
203
208
  outFile,
204
209
  }, update);
205
210
  if (!fs.existsSync(path.join(store.root, outFile)))
@@ -1,13 +1,73 @@
1
1
  function nonEmpty(value) {
2
2
  return typeof value === "string" && value.trim().length > 0;
3
3
  }
4
+ function concreteList(value, minimum = 1) {
5
+ return Array.isArray(value) && value.length >= minimum && value.every((item) => nonEmpty(item) && !/^(improve|modernize|make better|n\/a|none)$/i.test(item.trim()));
6
+ }
7
+ function validateV3Page(page, prefix, depth, tier) {
8
+ const errors = [];
9
+ const validRegisters = ["marketing-storytelling", "discovery-browse", "task-transaction", "account-status", "administration", "data-dense-utility", "authentication", "system-state"];
10
+ if (!page.register || !validRegisters.includes(page.register))
11
+ errors.push(`${prefix}.register is required`);
12
+ const expectedStrategy = depth === "restyle" ? "patch" : depth === "relayout" ? "recompose" : "rebuild-from-contracts";
13
+ if (page.sourceStrategy !== expectedStrategy)
14
+ errors.push(`${prefix}.sourceStrategy must be ${expectedStrategy} for ${depth}`);
15
+ const delta = page.structuralDelta;
16
+ if (!delta)
17
+ errors.push(`${prefix}.structuralDelta is required`);
18
+ else {
19
+ for (const key of ["existingModel", "proposedModel", "existingParadigm", "proposedParadigm", "depthHonesty"]) {
20
+ if (!nonEmpty(delta[key]) || /^(old|new|modern|expressive|same|n\/a)( layout| design)?$/i.test(delta[key].trim()))
21
+ errors.push(`${prefix}.structuralDelta.${key} must be concrete and page-specific`);
22
+ }
23
+ if (!concreteList(delta.materialChanges))
24
+ errors.push(`${prefix}.structuralDelta.materialChanges cannot be empty or generic`);
25
+ if (!Array.isArray(delta.survivingBoundaries) || !Array.isArray(delta.rebuiltBoundaries) || !concreteList(delta.preservedContracts) || !Array.isArray(delta.retainedPatterns) || !concreteList(delta.forbiddenCarryovers))
26
+ errors.push(`${prefix}.structuralDelta requires boundaries, preservation, retained-pattern rationale, and forbidden carryovers`);
27
+ if (delta.retainedPatterns?.some((item) => !nonEmpty(item.pattern) || !nonEmpty(item.rationale)))
28
+ errors.push(`${prefix}.structuralDelta retained patterns need rationale`);
29
+ const structural = [delta.proposedModel, delta.proposedParadigm, ...delta.materialChanges].join(" ");
30
+ if ((depth === "restructure" || depth === "reimagine") && !/\b(architect|component|boundary|workflow|navigation|interaction|model|task|rebuild|replace|merge|remove|reorder|hierarchy|workspace)\b/i.test(structural))
31
+ errors.push(`${prefix}: ${depth} cannot be satisfied by stylesheet, token, typography, card, or animation changes alone`);
32
+ if ((depth === "restructure" || depth === "reimagine") && /\b(same (sections|layout)|only (colou?r|font|style)|new (colou?r|font|shadow|radius))\b/i.test(structural))
33
+ errors.push(`${prefix}: structural delta contradicts selected depth ${depth}`);
34
+ }
35
+ const mobile = page.mobileBlueprint;
36
+ if (!mobile)
37
+ errors.push(`${prefix}.mobileBlueprint is required`);
38
+ else {
39
+ const fields = [mobile.primaryTask, mobile.firstViewportPurpose, mobile.safeArea, mobile.navigationModel, mobile.mobileOnlyComposition, mobile.mediaStrategy, mobile.motionStrategy, mobile.keyboardAndForms, mobile.composition390, mobile.fallback320, mobile.stackingRejection];
40
+ if (fields.some((field) => !nonEmpty(field)) || !concreteList(mobile.contentOrder, 2) || !concreteList(mobile.beforeFirstScroll) || !nonEmpty(mobile.primaryThumbAction?.action) || !nonEmpty(mobile.primaryThumbAction?.placement))
41
+ errors.push(`${prefix}.mobileBlueprint must specify task, order, first viewport, thumb action, safe areas, navigation, media, motion, forms, 390px, and 320px behavior`);
42
+ if (/^\s*stack(?:ed)?(?:\s+vertically)?[.!]?\s*$/i.test(mobile.mobileOnlyComposition) || /^\s*stack(?:ed)?(?:\s+vertically)?[.!]?\s*$/i.test(mobile.stackingRejection))
43
+ errors.push(`${prefix}.mobileBlueprint cannot be only “stack vertically”`);
44
+ if (!mobile.desktopTranslation || !Array.isArray(mobile.desktopTranslation.retained) || !Array.isArray(mobile.desktopTranslation.translated) || !Array.isArray(mobile.desktopTranslation.removed) || !Array.isArray(mobile.desktopTranslation.replaced))
45
+ errors.push(`${prefix}.mobileBlueprint must classify retained, translated, removed, and replaced desktop features`);
46
+ if (!Array.isArray(mobile.verificationChecks) || mobile.verificationChecks.length < 13)
47
+ errors.push(`${prefix}.mobileBlueprint must include the complete mobile verification checklist`);
48
+ }
49
+ if (tier === "expressive" || tier === "award") {
50
+ if (!page.expression && !nonEmpty(page.intentionalCalm))
51
+ errors.push(`${prefix} needs an expression contract or a documented intentional-calm rationale`);
52
+ if (page.expression) {
53
+ const expression = page.expression;
54
+ if ([expression.mechanism, expression.communicates, expression.projectFit, expression.location, expression.mobileTranslation, expression.reducedMotion, expression.fallback, expression.verification].some((field) => !nonEmpty(field)))
55
+ errors.push(`${prefix}.expression is incomplete`);
56
+ if (/\b(fade|slide|stagger|hover|scale|gradient|large typography)\b/i.test(expression.mechanism) && !/\b(content|state|progress|selection|navigation|transform|spatial|relationship)\b/i.test(expression.communicates))
57
+ errors.push(`${prefix}.expression is decorative-only`);
58
+ }
59
+ }
60
+ return errors;
61
+ }
4
62
  export function validatePlan(value) {
5
63
  const errors = [];
6
64
  const plan = value;
7
65
  if (!plan || typeof plan !== "object")
8
66
  return ["plan must be an object"];
9
- if (plan.version !== 2)
10
- errors.push("plan.version must be 2 (migrate legacy top-level sections into pages[])");
67
+ if (plan.version !== 2 && plan.version !== 3)
68
+ errors.push("plan.version must be 2 (legacy) or 3");
69
+ if (plan.version === 3 && plan.doctrineVersion !== 3)
70
+ errors.push("plan.doctrineVersion must be 3 for v3 plans");
11
71
  if (!nonEmpty(plan.request))
12
72
  errors.push("plan.request is required");
13
73
  if (!nonEmpty(plan.createdAt) || Number.isNaN(Date.parse(plan.createdAt)))
@@ -34,6 +94,8 @@ export function validatePlan(value) {
34
94
  const pagePrefix = `pages[${pageIndex}]`;
35
95
  if (!nonEmpty(page.id) || !nonEmpty(page.name))
36
96
  errors.push(`${pagePrefix} requires id and name`);
97
+ if (plan.version === 3 && plan.depth && plan.tier)
98
+ errors.push(...validateV3Page(page, pagePrefix, plan.depth, plan.tier));
37
99
  if (!Array.isArray(page.skills) || !page.skills.includes("ux") || !page.skills.includes("mobile"))
38
100
  errors.push(`${pagePrefix}.skills must include ux and mobile`);
39
101
  for (const globalSkill of plan.skillPolicy?.global ?? []) {
@@ -55,6 +117,15 @@ export function validatePlan(value) {
55
117
  errors.push(`${prefix} requires mobile and fallback treatments`);
56
118
  if (!Array.isArray(section.verification) || section.verification.length === 0)
57
119
  errors.push(`${prefix}.verification cannot be empty`);
120
+ if (plan.version === 3) {
121
+ for (const [criterionIndex, criterion] of (section.verification ?? []).entries()) {
122
+ const criterionPrefix = `${prefix}.verification[${criterionIndex}]`;
123
+ if (typeof criterion === "string")
124
+ errors.push(`${criterionPrefix} must be a typed verification criterion`);
125
+ else if (!nonEmpty(criterion.id) || !nonEmpty(criterion.claim) || !criterion.kind || criterion.pageId !== page.id || (criterion.sectionId !== undefined && criterion.sectionId !== section.id) || !Array.isArray(criterion.viewports) || criterion.viewports.length === 0)
126
+ errors.push(`${criterionPrefix} must associate id, claim, kind, page, section, and viewports`);
127
+ }
128
+ }
58
129
  if (section.status === "planned")
59
130
  errors.push(`${prefix} is still planned`);
60
131
  if ((section.status === "fallback" || section.status === "cut") && !nonEmpty(section.reason))
@@ -86,7 +157,7 @@ export function validatePlan(value) {
86
157
  for (const asset of section.assets ?? []) {
87
158
  if (!nonEmpty(asset.id) || !nonEmpty(asset.path) || !nonEmpty(asset.purpose))
88
159
  errors.push(`${prefix} contains an incomplete asset`);
89
- if (plan.doctrineVersion === 2 && (!asset.importance || !asset.preparation))
160
+ if ((plan.doctrineVersion === 2 || plan.doctrineVersion === 3) && (!asset.importance || !asset.preparation))
90
161
  errors.push(`${prefix} asset ${asset.id || "unknown"} needs importance and a preparation decision`);
91
162
  else if (asset.preparation) {
92
163
  if (!asset.importance || !["supporting", "key"].includes(asset.importance))
@@ -105,6 +176,14 @@ export function validatePlan(value) {
105
176
  }
106
177
  }
107
178
  }
179
+ if (plan.version === 3) {
180
+ if (!plan.coherence || !nonEmpty(plan.coherence.globalVisualLanguage) || !nonEmpty(plan.coherence.globalInteractionLanguage) || !concreteList(plan.coherence.sharedPrimitives) || !concreteList(plan.coherence.crossPageContinuity) || !concreteList(plan.coherence.prohibitedRepeatedShells))
181
+ errors.push("plan.coherence must define global languages, shared primitives, continuity, and prohibited repeated shells");
182
+ if (!Array.isArray(plan.coherence?.pageSpecificCompositions) || plan.coherence.pageSpecificCompositions.length !== (plan.pages?.length ?? 0))
183
+ errors.push("plan.coherence.pageSpecificCompositions must cover every page");
184
+ if ((plan.tier === "expressive" || plan.tier === "award") && !(plan.pages ?? []).some((page) => page.expression))
185
+ errors.push(`${plan.tier} multi-page plans require at least one authored expression mechanism across the product`);
186
+ }
108
187
  for (const skill of plan.skills ?? []) {
109
188
  if (!coveredSkills.has(skill))
110
189
  errors.push(`selected skill ${skill} is not assigned to any page`);
@@ -162,8 +241,8 @@ export function validateVerificationReport(value) {
162
241
  if (!report || typeof report !== "object")
163
242
  return ["verification report must be an object"];
164
243
  const errors = [];
165
- if (report.version !== 1)
166
- errors.push("verification report version must be 1");
244
+ if (report.version !== 1 && report.version !== 2)
245
+ errors.push("verification report version must be 1 (legacy) or 2");
167
246
  if (!Array.isArray(report.evidence))
168
247
  errors.push("verification evidence must be an array");
169
248
  if (report.refinement) {
@@ -195,6 +274,22 @@ export function validateVerificationReport(value) {
195
274
  errors.push(`verification.evidence[${index}] is incomplete`);
196
275
  if (item.timelineState && !timelineStates.has(item.timelineState))
197
276
  errors.push(`verification.evidence[${index}].timelineState is invalid`);
277
+ if (report.version === 2) {
278
+ if (!item.kind || !["visual", "interaction", "responsive", "preservation", "structural-depth"].includes(item.kind))
279
+ errors.push(`verification.evidence[${index}].kind is required`);
280
+ if (!nonEmpty(item.criterionId) || !nonEmpty(item.pageId) || !item.viewportClass)
281
+ errors.push(`verification.evidence[${index}] must associate criterionId, pageId, and viewportClass`);
282
+ if (item.viewportClass !== "non-visual" && !item.proof?.viewport)
283
+ errors.push(`verification.evidence[${index}] visual/responsive evidence requires an exact viewport`);
284
+ if (item.viewportClass === "desktop" && item.proof?.viewport && item.proof.viewport.width < 1200)
285
+ errors.push(`verification.evidence[${index}] desktop evidence must be at least 1200px wide`);
286
+ if (item.viewportClass === "mobile" && item.proof?.viewport && (item.proof.viewport.width < 375 || item.proof.viewport.width > 430))
287
+ errors.push(`verification.evidence[${index}] mobile evidence must be approximately 390px wide`);
288
+ if (item.viewportClass === "narrow-mobile" && item.proof?.viewport && item.proof.viewport.width > 340)
289
+ errors.push(`verification.evidence[${index}] narrow-mobile evidence must be approximately 320px wide`);
290
+ if ((item.viewportClass === "mobile" || item.viewportClass === "narrow-mobile") && (!Array.isArray(item.mobileChecks) || item.mobileChecks.length === 0))
291
+ errors.push(`verification.evidence[${index}] mobile evidence must name the checks it proves`);
292
+ }
198
293
  const proof = item.proof;
199
294
  if (!proof || !nonEmpty(proof.timestamp) || Number.isNaN(Date.parse(proof.timestamp))) {
200
295
  errors.push(`verification.evidence[${index}].proof requires a valid timestamp`);
@@ -0,0 +1,101 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validatePlan, validateVerificationReport } from "./artifacts.js";
4
+ import { buildDesignPlan } from "./design.js";
5
+ const page = {
6
+ id: "checkout",
7
+ name: "Checkout",
8
+ status: "skeleton",
9
+ canvasPos: { x: 0, y: 0 },
10
+ layout: {
11
+ id: "root", type: "column", label: "Checkout shell", children: [
12
+ { id: "promo", type: "hero", label: "Promotional introduction" },
13
+ { id: "form", type: "form", label: "Choose delivery and continue", children: [{ id: "continue", type: "button", label: "Continue", text: "Continue" }] },
14
+ { id: "cards", type: "card-grid", label: "Delivery option cards" },
15
+ ],
16
+ },
17
+ };
18
+ function v3Plan() {
19
+ const runtime = buildDesignPlan(page, { transformationDepth: "restructure", aesthetic: "trust", variance: 3, motion: 2 });
20
+ return {
21
+ version: 3,
22
+ doctrineVersion: 3,
23
+ request: "Restructure checkout without changing its contracts",
24
+ createdAt: "2026-07-14T00:00:00.000Z",
25
+ tier: "solid",
26
+ depth: "restructure",
27
+ skills: ["ux", "mobile"],
28
+ skillPolicy: { mode: "hybrid", global: ["ux", "mobile"], routingApproved: true, userAssignments: [] },
29
+ designRead: { register: "task transaction", concept: "continuous checkout", signature: "selection carries forward" },
30
+ coherence: {
31
+ globalVisualLanguage: "A restrained operational language with one accent and consistent state markers.",
32
+ globalInteractionLanguage: "Selections persist visibly and primary actions respond to readiness.",
33
+ sharedPrimitives: ["state marker", "task action"],
34
+ pageSpecificCompositions: [{ pageId: page.id, register: runtime.register, taskModel: runtime.mobileBlueprint.primaryTask, expressionLevel: "calm" }],
35
+ crossPageContinuity: ["Selection state carries into the next route"],
36
+ prohibitedRepeatedShells: ["Oversized headline plus decorative panel plus rounded card container"],
37
+ },
38
+ pages: [{
39
+ id: page.id,
40
+ name: page.name,
41
+ register: runtime.register,
42
+ sourceStrategy: runtime.sourceStrategy,
43
+ structuralDelta: runtime.structuralDelta,
44
+ mobileBlueprint: runtime.mobileBlueprint,
45
+ skills: ["ux", "mobile"],
46
+ sections: [{
47
+ id: "task",
48
+ name: "Checkout task",
49
+ layoutFamily: "continuous decision workspace",
50
+ skills: ["ux", "mobile"],
51
+ interactions: ["select delivery"],
52
+ mobile: "Task controls precede supporting content and keep the continuation action reachable.",
53
+ fallback: "Semantic fieldset and submit action",
54
+ verification: [{ id: "checkout-depth", claim: "Implemented checkout matches the approved task-first architecture", kind: "structural-depth", pageId: page.id, sectionId: "task", viewports: ["desktop", "mobile", "narrow-mobile"] }],
55
+ assets: [],
56
+ status: "shipped",
57
+ }],
58
+ }],
59
+ preservationManifest: ".dreative/preservation.json",
60
+ decisionLedger: ".dreative/ledger.json",
61
+ };
62
+ }
63
+ test("v3 plan accepts independent solid ambition with restructure depth", () => {
64
+ assert.deepEqual(validatePlan(v3Plan()), []);
65
+ });
66
+ test("v3 validator rejects stylesheet-only deep transformation", () => {
67
+ const plan = v3Plan();
68
+ plan.pages[0].structuralDelta = {
69
+ ...plan.pages[0].structuralDelta,
70
+ proposedModel: "Same sections with modern expressive styling",
71
+ proposedParadigm: "Same layout with new colors and fonts",
72
+ materialChanges: ["New colors, fonts, card radii, shadows, and fade animations"],
73
+ };
74
+ assert.ok(validatePlan(plan).some((error) => error.includes("cannot be satisfied")));
75
+ });
76
+ test("v3 validator rejects stack-only mobile blueprint", () => {
77
+ const plan = v3Plan();
78
+ plan.pages[0].mobileBlueprint.mobileOnlyComposition = "stack vertically";
79
+ assert.ok(validatePlan(plan).some((error) => error.includes("stack vertically")));
80
+ });
81
+ test("verification v2 requires associated page, criterion, kind, and real mobile viewports", () => {
82
+ const report = {
83
+ version: 2,
84
+ generatedAt: "2026-07-14T00:00:00.000Z",
85
+ evidence: [{
86
+ id: "mobile-check",
87
+ criterion: "Mobile task is usable",
88
+ criterionId: "checkout-mobile",
89
+ pageId: "checkout",
90
+ kind: "responsive",
91
+ viewportClass: "mobile",
92
+ mobileChecks: ["no-horizontal-overflow"],
93
+ status: "pass",
94
+ evidence: "Browser screenshot and interaction run",
95
+ proof: { timestamp: "2026-07-14T00:00:00.000Z", viewport: { width: 390, height: 844 }, artifactPath: ".dreative/screenshots/checkout-mobile.png" },
96
+ }],
97
+ };
98
+ assert.deepEqual(validateVerificationReport(report), []);
99
+ report.evidence[0].proof.viewport = { width: 768, height: 844 };
100
+ assert.ok(validateVerificationReport(report).some((error) => error.includes("approximately 390px")));
101
+ });