dreative 0.5.1 → 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.
Files changed (48) hide show
  1. package/README.md +49 -27
  2. package/dist/cli/audit.js +206 -0
  3. package/dist/cli/audit.test.js +79 -0
  4. package/dist/cli/docsCheck.js +163 -0
  5. package/dist/cli/docsCheck.test.js +8 -0
  6. package/dist/cli/index.js +45 -9
  7. package/dist/server/preview.js +73 -73
  8. package/dist/server/store.js +11 -0
  9. package/dist/shared/artifacts.js +220 -0
  10. package/dist/shared/design.js +45 -24
  11. package/dist/shared/ruleSystem.js +220 -0
  12. package/dist/shared/ruleSystem.test.js +262 -0
  13. package/dist/shared/skillSystem.js +173 -0
  14. package/dist/shared/skillSystem.test.js +110 -0
  15. package/dist/ui/assets/{index--vztc_MR.js → index-CKwmbx2j.js} +13 -13
  16. package/dist/ui/index.html +12 -12
  17. package/package.json +5 -3
  18. package/skill/dreative/DESIGN.md +144 -108
  19. package/skill/dreative/PLAN.md +285 -116
  20. package/skill/dreative/SKILL.md +237 -144
  21. package/skill/dreative/frameworks/nextjs.md +13 -0
  22. package/skill/dreative/frameworks/react-vite.md +12 -0
  23. package/skill/dreative/frameworks/styling.md +14 -0
  24. package/skill/dreative/frameworks/sveltekit.md +10 -0
  25. package/skill/dreative/frameworks/vue-nuxt.md +12 -0
  26. package/skill/dreative/recipes/3d-recipes.md +44 -0
  27. package/skill/dreative/recipes/cinematic-recipes.md +19 -0
  28. package/skill/dreative/recipes/immersive-recipes.md +18 -0
  29. package/skill/dreative/recipes/media-recipes.md +60 -0
  30. package/skill/dreative/recipes/motion-recipes.md +68 -0
  31. package/skill/dreative/references/ARTIFACTS.md +196 -0
  32. package/skill/dreative/references/REFLEX_FONTS.json +44 -0
  33. package/skill/dreative/references/RULES.json +186 -0
  34. package/skill/dreative/references/SKILL_CONTRACT.md +30 -0
  35. package/skill/dreative/references/TIERS.md +44 -0
  36. package/skill/dreative/schemas/plan.schema.json +309 -0
  37. package/skill/dreative/schemas/verify.schema.json +75 -0
  38. package/skill/dreative/skills/3d.md +94 -267
  39. package/skill/dreative/skills/cinematic.md +56 -232
  40. package/skill/dreative/skills/experimental.md +108 -95
  41. package/skill/dreative/skills/immersive.md +61 -223
  42. package/skill/dreative/skills/interaction.md +9 -1
  43. package/skill/dreative/skills/media.md +149 -564
  44. package/skill/dreative/skills/mobile.md +129 -117
  45. package/skill/dreative/skills/motion.md +126 -256
  46. package/skill/dreative/skills/refined.md +116 -102
  47. package/skill/dreative/skills/ux.md +155 -144
  48. package/dist/server/ai.js +0 -177
@@ -0,0 +1,220 @@
1
+ function normalized(value) {
2
+ return value.trim().toLocaleLowerCase().replace(/\s+/g, " ");
3
+ }
4
+ function distinct(values) {
5
+ return [...new Set(values.map(normalized).filter(Boolean))];
6
+ }
7
+ function specific(value, minLength) {
8
+ if (!value || value.trim().length < minLength)
9
+ return false;
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
+ }
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
+ }
18
+ export function validateRuleControls(plan, registry, reflexFonts, verification) {
19
+ if (plan.doctrineVersion !== 2)
20
+ return [];
21
+ const errors = [];
22
+ const rules = new Map(registry.rules.map((rule) => [rule.id, rule]));
23
+ const evidence = new Map((verification?.evidence ?? []).map((item) => [item.id, item]));
24
+ for (const exception of plan.ruleExceptions ?? []) {
25
+ if (exception.decision !== "substituted")
26
+ errors.push(`${exception.ruleId || "unknown rule"}: decision must be substituted`);
27
+ const rule = rules.get(exception.ruleId);
28
+ if (!rule) {
29
+ errors.push(`unknown rule exception: ${exception.ruleId}`);
30
+ continue;
31
+ }
32
+ if (rule.category === "hard-gate" || !rule.exceptionAllowed) {
33
+ errors.push(`hard gate ${exception.ruleId} cannot be substituted`);
34
+ continue;
35
+ }
36
+ if (!specific(exception.reason, 30))
37
+ errors.push(`${exception.ruleId}: reason is vague or too short`);
38
+ if (!specific(exception.alternative, 40))
39
+ errors.push(`${exception.ruleId}: alternative is vague or too short`);
40
+ const criteria = Array.isArray(exception.successCriteria) ? exception.successCriteria : [];
41
+ if (distinct(criteria).length < 2 || criteria.some((criterion) => criterion.trim().length < 12))
42
+ errors.push(`${exception.ruleId}: needs at least two observable success criteria`);
43
+ if (!plan.implementationStartedAt || Number.isNaN(Date.parse(plan.implementationStartedAt)))
44
+ errors.push(`${exception.ruleId}: implementationStartedAt is required to prove planning-time declaration`);
45
+ else if (Number.isNaN(Date.parse(exception.declaredAt)) || Date.parse(exception.declaredAt) > Date.parse(plan.implementationStartedAt))
46
+ errors.push(`${exception.ruleId}: exception must be declared before implementation starts`);
47
+ const evidenceIds = Array.isArray(exception.evidenceIds) ? exception.evidenceIds : [];
48
+ if (evidenceIds.length === 0)
49
+ errors.push(`${exception.ruleId}: substitution requires verification evidence`);
50
+ for (const id of evidenceIds) {
51
+ const item = evidence.get(id);
52
+ if (!item)
53
+ errors.push(`${exception.ruleId}: missing verification evidence ${id}`);
54
+ else if (item.status !== "pass")
55
+ errors.push(`${exception.ruleId}: evidence ${id} is not passing`);
56
+ }
57
+ }
58
+ if (!plan.fontDecision) {
59
+ errors.push("fontDecision is required for doctrineVersion 2 plans");
60
+ }
61
+ else {
62
+ const decision = plan.fontDecision;
63
+ const candidates = Array.isArray(decision.candidates) ? decision.candidates : [];
64
+ if (distinct(candidates.map((candidate) => candidate.name)).length < 3)
65
+ errors.push("fontDecision requires at least three distinct candidates");
66
+ if (!candidates.some((candidate) => normalized(candidate.name) === normalized(decision.selected)))
67
+ errors.push("selected font must appear in font candidates");
68
+ const reflexSet = new Set(reflexFonts.fonts.map(normalized));
69
+ for (const candidate of candidates) {
70
+ if (candidate.reflex !== reflexSet.has(normalized(candidate.name)))
71
+ errors.push(`${candidate.name}: reflex marker does not match REFLEX_FONTS.json`);
72
+ }
73
+ const selectedIsReflex = reflexSet.has(normalized(decision.selected));
74
+ if (selectedIsReflex) {
75
+ const reason = decision.justification ?? "";
76
+ const validKinds = new Set(reflexFonts.validReasonKinds ?? []);
77
+ if (!(decision.reasonKinds ?? []).some((kind) => validKinds.has(kind)))
78
+ errors.push(`${decision.selected}: reflex font requires at least one registered reason kind`);
79
+ if (!specific(reason, 40) || reflexFonts.invalidGenericReasons.some((phrase) => normalized(reason).includes(normalized(phrase))))
80
+ errors.push(`${decision.selected}: reflex font requires a specific non-generic justification`);
81
+ }
82
+ if ((decision.recentDisplayFonts ?? []).map(normalized).includes(normalized(decision.selected)) && !specific(decision.repeatJustification, 40))
83
+ errors.push(`${decision.selected}: repeating a recent display font requires a stronger justification`);
84
+ }
85
+ if (plan.tier === "expressive" || plan.tier === "award") {
86
+ const strategy = plan.creativeStrategy;
87
+ if (!strategy)
88
+ errors.push(`${plan.tier} plans require a diversity-or-development creativeStrategy`);
89
+ else if (strategy.path === "diversity") {
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");
94
+ }
95
+ else {
96
+ if (!specific(strategy.signatureMechanism, 12))
97
+ errors.push("development path requires a named signature mechanism");
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
+ }
188
+ }
189
+ }
190
+ if (plan.skills.includes("experimental")) {
191
+ const experimental = plan.experimentalPlan;
192
+ if (!experimental)
193
+ errors.push("experimental skill requires experimentalPlan");
194
+ else {
195
+ const candidates = Array.isArray(experimental.candidates) ? experimental.candidates : [];
196
+ const covered = new Set(candidates.map((candidate) => candidate.sectionId));
197
+ for (const sectionId of experimental.majorSectionIds ?? []) {
198
+ if (!covered.has(sectionId))
199
+ errors.push(`experimental exploration is missing major section ${sectionId}`);
200
+ }
201
+ const selected = candidates.filter((candidate) => candidate.selected);
202
+ if (selected.length < 2 || selected.length > 3)
203
+ errors.push("experimental delivery must select only the strongest two or three provocations");
204
+ if (candidates.some((candidate) => candidate.idea.trim().length < 20))
205
+ errors.push("experimental candidates must be concrete, non-obvious ideas");
206
+ }
207
+ }
208
+ if ((plan.recipeAccess?.length ?? 0) > 0) {
209
+ if (!plan.conceptExploration || distinct(plan.conceptExploration.concepts.map((concept) => concept.concept)).length < 3)
210
+ errors.push("three original concepts must be recorded before recipe access");
211
+ else {
212
+ const exploredAt = Date.parse(plan.conceptExploration.recordedAt);
213
+ for (const access of plan.recipeAccess ?? []) {
214
+ if (Number.isNaN(Date.parse(access.loadedAt)) || Date.parse(access.loadedAt) < exploredAt)
215
+ errors.push(`${access.file}: recipe was loaded before concept exploration was recorded`);
216
+ }
217
+ }
218
+ }
219
+ return errors;
220
+ }
@@ -0,0 +1,262 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { validateRuleControls } from "./ruleSystem.js";
6
+ const registry = JSON.parse(fs.readFileSync(path.resolve("skill/dreative/references/RULES.json"), "utf-8"));
7
+ const reflex = JSON.parse(fs.readFileSync(path.resolve("skill/dreative/references/REFLEX_FONTS.json"), "utf-8"));
8
+ const planned = "2026-01-01T00:00:00.000Z";
9
+ const started = "2026-01-01T01:00:00.000Z";
10
+ function basePlan(tier = "award") {
11
+ return {
12
+ version: 2,
13
+ doctrineVersion: 2,
14
+ request: "Create an ambitious archival experience",
15
+ createdAt: planned,
16
+ implementationStartedAt: started,
17
+ tier,
18
+ depth: "reimagine",
19
+ skills: ["ux", "mobile", "motion", "media"],
20
+ skillPolicy: { mode: "hybrid", global: ["ux", "mobile"], routingApproved: true, userAssignments: [] },
21
+ designRead: { register: "archival editorial", concept: "living catalog", signature: "transforming specimen" },
22
+ fontDecision: {
23
+ selected: "Source Serif 4",
24
+ candidates: [
25
+ { name: "Source Serif 4", reflex: false, rationale: "Variable optical sizes support the archival scale shifts." },
26
+ { name: "Newsreader", reflex: false, rationale: "Editorial contrast supports document-led hierarchy." },
27
+ { name: "Archivo", reflex: false, rationale: "Catalog labeling connects directly to the archival subject." },
28
+ ],
29
+ recentDisplayFonts: [],
30
+ },
31
+ creativeStrategy: {
32
+ path: "diversity",
33
+ mechanisms: ["variable-type morph", "depth-stack", "drag-inertia", "mask-dissolve"],
34
+ drivers: ["scroll", "pointer", "time"],
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
+ },
50
+ pages: [
51
+ {
52
+ id: "home",
53
+ name: "Home",
54
+ skills: ["ux", "mobile", "motion", "media"],
55
+ sections: [
56
+ {
57
+ id: "hero",
58
+ name: "Hero",
59
+ layoutFamily: "archival-index",
60
+ skills: ["ux", "mobile", "motion", "media"],
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
+ },
75
+ mobile: "stacked specimen",
76
+ fallback: "static catalog",
77
+ verification: ["Signature responds to input"],
78
+ assets: [],
79
+ status: "shipped",
80
+ },
81
+ ],
82
+ },
83
+ ],
84
+ preservationManifest: ".dreative/preservation.json",
85
+ decisionLedger: ".dreative/ledger.json",
86
+ };
87
+ }
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
+ }));
98
+ return {
99
+ version: 1,
100
+ generatedAt: started,
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
+ },
117
+ };
118
+ }
119
+ test("valid default award plan", () => {
120
+ assert.deepEqual(validateRuleControls(basePlan(), registry, reflex, verification()), []);
121
+ });
122
+ test("valid key-asset substitution for a typography-only experience", () => {
123
+ const plan = basePlan();
124
+ plan.ruleExceptions = [
125
+ {
126
+ ruleId: "media.keyAssetTreatment",
127
+ decision: "substituted",
128
+ declaredAt: planned,
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"],
133
+ },
134
+ ];
135
+ assert.deepEqual(validateRuleControls(plan, registry, reflex, verification("type-system-desktop", "type-system-mobile")), []);
136
+ });
137
+ test("invalid vague creative-rule exception", () => {
138
+ const plan = basePlan();
139
+ plan.ruleExceptions = [
140
+ {
141
+ ruleId: "media.keyAssetTreatment",
142
+ decision: "substituted",
143
+ declaredAt: planned,
144
+ reason: "It did not fit",
145
+ alternative: "Typography felt better",
146
+ successCriteria: ["Looks good", "Feels spatial"],
147
+ evidenceIds: ["type"],
148
+ },
149
+ ];
150
+ const errors = validateRuleControls(plan, registry, reflex, verification("type"));
151
+ assert.ok(errors.some((error) => error.includes("vague")));
152
+ });
153
+ test("attempted hard-gate exception", () => {
154
+ const plan = basePlan();
155
+ plan.ruleExceptions = [
156
+ {
157
+ ruleId: "preservation.noLoss",
158
+ decision: "substituted",
159
+ declaredAt: planned,
160
+ reason: "The old form was intentionally removed to simplify the new conversion route for this campaign.",
161
+ alternative: "A replacement conversion control carries the same submitted data into the existing backend workflow.",
162
+ successCriteria: ["Users can submit the same required fields", "The existing backend receives an equivalent payload"],
163
+ evidenceIds: ["form"],
164
+ },
165
+ ];
166
+ assert.ok(validateRuleControls(plan, registry, reflex, verification("form")).some((error) => error.includes("cannot be substituted")));
167
+ });
168
+ test("valid diversity plan", () => {
169
+ const plan = basePlan("expressive");
170
+ assert.deepEqual(validateRuleControls(plan, registry, reflex, verification()), []);
171
+ });
172
+ test("valid development plan", () => {
173
+ const plan = basePlan();
174
+ plan.creativeStrategy = {
175
+ path: "development",
176
+ signatureMechanism: "An archival sheet transforms across the complete journey",
177
+ states: ["Loose sheets form a catalog index", "Sheets become a draggable research surface", "Sheets collapse into a spatial tunnel"],
178
+ secondaryMechanisms: ["kinetic labels", "material lighting"],
179
+ drivers: ["scroll", "pointer"],
180
+ };
181
+ assert.deepEqual(validateRuleControls(plan, registry, reflex, verification()), []);
182
+ });
183
+ test("invalid decorative-only expressive plan", () => {
184
+ const plan = basePlan();
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
+ };
191
+ const errors = validateRuleControls(plan, registry, reflex, verification());
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")));
218
+ });
219
+ test("valid reflex-font justification", () => {
220
+ const plan = basePlan("premium");
221
+ plan.fontDecision = {
222
+ selected: "Inter",
223
+ candidates: [
224
+ { name: "Inter", reflex: true, rationale: "Existing authenticated product metrics and density depend on it." },
225
+ { name: "Switzer", reflex: false, rationale: "Comparable product clarity with more character." },
226
+ { name: "Source Sans 3", reflex: false, rationale: "Broad language support and efficient variable delivery." },
227
+ ],
228
+ recentDisplayFonts: [],
229
+ reasonKinds: ["existing-brand", "design-system-metrics"],
230
+ justification: "Inter is embedded throughout the authenticated product, preserves established density metrics, and avoids layout regressions across translated views.",
231
+ };
232
+ assert.deepEqual(validateRuleControls(plan, registry, reflex, verification()), []);
233
+ });
234
+ test("invalid generic font justification", () => {
235
+ const plan = basePlan("premium");
236
+ plan.fontDecision = {
237
+ selected: "Inter",
238
+ candidates: [
239
+ { name: "Inter", reflex: true, rationale: "A familiar interface choice." },
240
+ { name: "Switzer", reflex: false, rationale: "A product-oriented alternative." },
241
+ { name: "Source Sans 3", reflex: false, rationale: "A broad-language alternative." },
242
+ ],
243
+ recentDisplayFonts: [],
244
+ reasonKinds: [],
245
+ justification: "Inter looks clean and modern for this interface.",
246
+ };
247
+ assert.ok(validateRuleControls(plan, registry, reflex, verification()).some((error) => error.includes("non-generic")));
248
+ });
249
+ test("experimental exploration selects only two shipped provocations", () => {
250
+ const plan = basePlan("expressive");
251
+ plan.skills.push("experimental");
252
+ plan.pages[0].skills.push("experimental");
253
+ plan.experimentalPlan = {
254
+ majorSectionIds: ["hero", "archive", "closing"],
255
+ candidates: [
256
+ { id: "p1", sectionId: "hero", idea: "The title behaves as a physical catalog drawer opened by drag.", selected: true },
257
+ { id: "p2", sectionId: "archive", idea: "Research sheets reorganize into a navigable spatial evidence wall.", selected: true },
258
+ { id: "p3", sectionId: "closing", idea: "The final mark briefly assembles from all prior catalog coordinates.", selected: false },
259
+ ],
260
+ };
261
+ assert.deepEqual(validateRuleControls(plan, registry, reflex, verification()), []);
262
+ });
@@ -0,0 +1,173 @@
1
+ export const SKILL_DEFINITIONS = [
2
+ {
3
+ name: "ux",
4
+ description: "Working navigation, forms, states, keyboard access, focus, and interaction audits.",
5
+ dependencies: [],
6
+ signature: /\b(ux|accessib|a11y|forms?|navigation|keyboard|focus|states?|dashboard|product ui)\b/i,
7
+ },
8
+ {
9
+ name: "mobile",
10
+ description: "Mobile-native composition, touch ergonomics, responsive effects, and phone verification.",
11
+ dependencies: ["ux"],
12
+ signature: /\b(mobile|phone|tablet|responsive|touch|thumb|small screen)\b/i,
13
+ },
14
+ {
15
+ name: "refined",
16
+ description: "Premium clean business, DTC, and commerce design with restraint and strong photography.",
17
+ dependencies: ["ux", "mobile"],
18
+ signature: /\b(clean (and )?(modern|professional)|modern clean|business (site|website|use)|corporate|professional look|premium (minimal|clean)|dtc|d2c|e[- ]?commerce|ecommerce|shopify|calm|understated)\b/i,
19
+ },
20
+ {
21
+ name: "motion",
22
+ description: "Scroll choreography, entrances, kinetic type, springs, parallax, and transitions.",
23
+ dependencies: ["ux", "mobile"],
24
+ signature: /\b(animat|motion|parallax|scroll[- ]?(driven|trigger|story|choreo)|gsap|framer|lenis|marquee|kinetic|stagger|reveal|transition)\b/i,
25
+ },
26
+ {
27
+ name: "interaction",
28
+ description: "Hover craft, magnetic controls, cursor effects, tactile feedback, and state morphs.",
29
+ dependencies: ["ux", "mobile"],
30
+ signature: /\b(micro[- ]?interaction|hover (effect|state)s?|cursor|magnetic|tilt|spotlight|glow|ripple|tactile|interactive|draggable)\b/i,
31
+ },
32
+ {
33
+ name: "media",
34
+ description: "Generated and sourced image/video production, grading, motion treatments, and media planes.",
35
+ dependencies: ["ux", "mobile"],
36
+ signature: /\b(generated? (image|video|media)|hero video|image treatment|media plane|gallery|photography|video loop|distortion|living thumbnail)\b/i,
37
+ },
38
+ {
39
+ name: "3d",
40
+ description: "Three.js/R3F, WebGL, shaders, particles, models, lighting, and robust fallbacks.",
41
+ dependencies: ["ux", "mobile"],
42
+ signature: /\b(3d|three\.?js|webgl|r3f|shader|glsl|particles?|point ?cloud|globe|mesh gradient|orbit|spline)\b/i,
43
+ },
44
+ {
45
+ name: "immersive",
46
+ description: "Persistent spatial stages, preloaders, scene transitions, and scroll-as-journey.",
47
+ dependencies: ["motion", "interaction", "media", "ux", "mobile"],
48
+ signature: /\b(immersive|award[- ]?(site|winning)|awwwards|spatial|scene[- ]?based|world|camera (move|travel|path)|preloader|scroll (journey|story|as travel)|explorable|experience site)\b/i,
49
+ },
50
+ {
51
+ name: "cinematic",
52
+ description: "Shader-graded experiential interfaces, living surfaces, gesture exploration, and sound.",
53
+ dependencies: ["motion", "interaction", "media", "ux", "mobile"],
54
+ signature: /\b(cinematic|experiential|unseen\.co|fluid (sim(ulation)?|distortion)|gpgpu|drag[- ]to[- ]explore|click ?(&|and) ?hold|film grain|graded|velocity[- ]reactive|sound design|ambient (audio|sound))\b/i,
55
+ },
56
+ {
57
+ name: "experimental",
58
+ description: "Unconventional composition, material shifts, provocations, and high-variance creative direction.",
59
+ dependencies: ["motion", "interaction", "media", "ux", "mobile"],
60
+ signature: /\b(experimental|crazy|bizarre|never seen before|more creative|wow factor|unconventional|unexpected|provocation)\b/i,
61
+ },
62
+ ];
63
+ const BY_NAME = new Map(SKILL_DEFINITIONS.map((skill) => [skill.name, skill]));
64
+ export function detectSpecialistSkills(texts) {
65
+ const haystack = texts.filter(Boolean).join("\n");
66
+ return SKILL_DEFINITIONS.filter((skill) => skill.signature.test(haystack)).map((skill) => skill.name);
67
+ }
68
+ export function resolveSkillDependencies(requested) {
69
+ const resolved = new Set(["ux", "mobile"]);
70
+ const visit = (name) => {
71
+ if (resolved.has(name))
72
+ return;
73
+ for (const dependency of BY_NAME.get(name)?.dependencies ?? [])
74
+ visit(dependency);
75
+ resolved.add(name);
76
+ };
77
+ for (const name of requested)
78
+ visit(name);
79
+ return SKILL_DEFINITIONS.map((skill) => skill.name).filter((name) => resolved.has(name));
80
+ }
81
+ function skillScore(name, texts) {
82
+ const definition = BY_NAME.get(name);
83
+ if (!definition)
84
+ return 0;
85
+ const haystack = texts.filter(Boolean).join("\n");
86
+ const flags = definition.signature.flags.includes("g") ? definition.signature.flags : `${definition.signature.flags}g`;
87
+ return [...haystack.matchAll(new RegExp(definition.signature.source, flags))].length;
88
+ }
89
+ /**
90
+ * Hybrid multi-page routing: user selection is authoritative, explicit page
91
+ * assignments are locked, and only selected-but-unassigned treatments are
92
+ * placed automatically. Detected unselected skills are returned as suggestions
93
+ * and never activated silently.
94
+ */
95
+ export function routeSkillsAcrossPages(request) {
96
+ const selected = [...new Set(request.selected)];
97
+ const resolved = resolveSkillDependencies(selected);
98
+ const byPageSets = new Map(request.pages.map((page) => [page.id, new Set(["ux", "mobile"])]));
99
+ for (const [pageId, skills] of Object.entries(request.assignments ?? {})) {
100
+ const target = byPageSets.get(pageId);
101
+ if (!target)
102
+ continue;
103
+ for (const skill of skills) {
104
+ if (!resolved.includes(skill))
105
+ continue;
106
+ for (const dependency of resolveSkillDependencies([skill]))
107
+ target.add(dependency);
108
+ }
109
+ }
110
+ const optionalSelected = selected.filter((skill) => skill !== "ux" && skill !== "mobile");
111
+ const unassigned = optionalSelected.filter((skill) => ![...byPageSets.values()].some((skills) => skills.has(skill)));
112
+ if (request.autoRouteUnassigned !== false && request.pages.length > 0) {
113
+ for (const skill of unassigned) {
114
+ const ranked = request.pages
115
+ .map((page, index) => ({
116
+ page,
117
+ index,
118
+ score: skillScore(skill, page.texts),
119
+ load: [...(byPageSets.get(page.id) ?? [])].filter((name) => name !== "ux" && name !== "mobile").length,
120
+ }))
121
+ .sort((a, b) => b.score - a.score || a.load - b.load || a.index - b.index);
122
+ const target = byPageSets.get(ranked[0].page.id);
123
+ for (const dependency of resolveSkillDependencies([skill]))
124
+ target.add(dependency);
125
+ }
126
+ }
127
+ const suggestions = {};
128
+ for (const page of request.pages) {
129
+ suggestions[page.id] = detectSpecialistSkills(page.texts).filter((skill) => !resolved.includes(skill));
130
+ }
131
+ const byPage = Object.fromEntries(request.pages.map((page) => [
132
+ page.id,
133
+ SKILL_DEFINITIONS.map((definition) => definition.name).filter((name) => byPageSets.get(page.id)?.has(name)),
134
+ ]));
135
+ return {
136
+ selected,
137
+ resolved,
138
+ byPage,
139
+ suggestions,
140
+ unassigned: request.autoRouteUnassigned === false ? unassigned : [],
141
+ };
142
+ }
143
+ export function resolveAmbitionTier(input) {
144
+ const haystack = [input.aesthetic, ...(input.texts ?? [])].filter(Boolean).join(" ");
145
+ if (/\b(award|awwwards|immersive|cinematic|experimental|unseen\.co)\b/i.test(haystack) || input.motion >= 9)
146
+ return "award";
147
+ if (input.motion >= 7 || input.variance >= 8)
148
+ return "expressive";
149
+ if (input.motion >= 4 || input.variance >= 5)
150
+ return "premium";
151
+ return "solid";
152
+ }
153
+ export const TIER_REQUIREMENTS = {
154
+ solid: [
155
+ "All existing behavior is preserved and the UX audit passes.",
156
+ "Desktop and mobile layouts are verified with keyboard and reduced-motion coverage.",
157
+ ],
158
+ premium: [
159
+ "Solid-tier requirements pass.",
160
+ "The page has a clear design read, one signature detail, and a completed craft pass.",
161
+ "Media is intentionally cropped/graded and interaction states are designed.",
162
+ ],
163
+ expressive: [
164
+ "Premium-tier requirements pass.",
165
+ "At least three distinct, purposeful motion or interaction moments ship with fallbacks.",
166
+ "Heavy effects are measured on desktop and translated deliberately for mobile.",
167
+ ],
168
+ award: [
169
+ "Expressive-tier requirements pass.",
170
+ "The experience has a distinctive spatial or media system, not isolated decoration.",
171
+ "Runtime, asset-weight, frame-time, occlusion, and fallback evidence is recorded.",
172
+ ],
173
+ };