@neatico/visual-core 0.0.0-stage → 0.14.0

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -2
  3. package/dist/blueprints.d.ts +20 -0
  4. package/dist/blueprints.js +43 -0
  5. package/dist/cjs/blueprints.js +48 -0
  6. package/dist/cjs/composition.js +68 -0
  7. package/dist/cjs/compositionScenes.js +51 -0
  8. package/dist/cjs/content.js +242 -0
  9. package/dist/cjs/designBlueprint.js +34 -0
  10. package/dist/cjs/designSystem.js +126 -0
  11. package/dist/cjs/editorial.js +41 -0
  12. package/dist/cjs/fontCatalog.js +42 -0
  13. package/dist/cjs/geometry.js +89 -0
  14. package/dist/cjs/index.js +31 -0
  15. package/dist/cjs/layoutContracts.js +27 -0
  16. package/dist/cjs/layouts.js +103 -0
  17. package/dist/cjs/package.json +1 -0
  18. package/dist/cjs/pagination.js +36 -0
  19. package/dist/cjs/scenes.js +31 -0
  20. package/dist/cjs/socialLayouts.js +471 -0
  21. package/dist/cjs/text.js +87 -0
  22. package/dist/composition.d.ts +17 -0
  23. package/dist/composition.js +63 -0
  24. package/dist/compositionScenes.d.ts +19 -0
  25. package/dist/compositionScenes.js +47 -0
  26. package/dist/content.d.ts +104 -0
  27. package/dist/content.js +234 -0
  28. package/dist/designBlueprint.d.ts +26 -0
  29. package/dist/designBlueprint.js +29 -0
  30. package/dist/designSystem.d.ts +58 -0
  31. package/dist/designSystem.js +119 -0
  32. package/dist/editorial.d.ts +6 -0
  33. package/dist/editorial.js +35 -0
  34. package/dist/fontCatalog.d.ts +11 -0
  35. package/dist/fontCatalog.js +36 -0
  36. package/dist/geometry.d.ts +23 -0
  37. package/dist/geometry.js +83 -0
  38. package/dist/index.d.ts +15 -0
  39. package/dist/index.js +15 -0
  40. package/dist/layoutContracts.d.ts +15 -0
  41. package/dist/layoutContracts.js +22 -0
  42. package/dist/layouts.d.ts +2 -0
  43. package/dist/layouts.js +100 -0
  44. package/dist/pagination.d.ts +14 -0
  45. package/dist/pagination.js +32 -0
  46. package/dist/scenes.d.ts +7 -0
  47. package/dist/scenes.js +28 -0
  48. package/dist/socialLayouts.d.ts +390 -0
  49. package/dist/socialLayouts.js +468 -0
  50. package/dist/text.d.ts +8 -0
  51. package/dist/text.js +81 -0
  52. package/package.json +55 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 PowerDuckie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,138 @@
1
- # Temporary Holding Version
1
+ # @neatico/visual-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framework-independent building blocks for editable visual products, extracted from [Neatico](https://www.neatico.com/?utm_source=github&utm_medium=readme&utm_campaign=visual-core).
4
+
5
+ Create visuals with AI at **[neatico.com](https://www.neatico.com/?utm_source=npm&utm_medium=readme&utm_campaign=visual-core)**, or use this package to build your own editor, layout pipeline, or scene extension.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @neatico/visual-core
11
+ ```
12
+
13
+ This package provides native ES modules and TypeScript declarations. It uses the MIT-licensed `linebreak` package for Unicode line opportunities and does not require React, a browser, a database, or an AI provider. Node.js 20 or later is supported. CommonJS projects can use `require()` or dynamic `import()`.
14
+
15
+ ## Preserve transforms when ungrouping
16
+
17
+ ```ts
18
+ import { flattenGroupLayout } from "@neatico/visual-core";
19
+
20
+ const group = { x: 100, y: 50, width: 400, height: 300, rotation: 30 };
21
+ const child = { x: 20, y: 30, width: 120, height: 60, rotation: 15 };
22
+ const ungrouped = flattenGroupLayout(group, child);
23
+ ```
24
+
25
+ Coordinates are in logical canvas units. Rotations and skew angles are in degrees. Transforms use the center of the element as their origin. `flattenGroupLayout` preserves additional child properties and returns a new object. It supports translation, rotation, scaling, skew, and horizontal or vertical flips. Nonfinite values, negative dimensions, and degenerate ungrouping transforms fail explicitly.
26
+
27
+ Additional exports include `layoutMatrix`, `multiplyMatrix`, `layoutBounds`, and the `Layout` and `Matrix` types. Matrices use the six-value CSS affine format `[a, b, c, d, e, f]`.
28
+
29
+ ## Wrap text using your font measurements
30
+
31
+ ```ts
32
+ import { wrapCopy } from "@neatico/visual-core";
33
+
34
+ const context = document.createElement("canvas").getContext("2d")!;
35
+ await document.fonts.load("400 24px Inter");
36
+ const lines = wrapCopy(
37
+ "One idea. Every format.",
38
+ 240,
39
+ 24,
40
+ false,
41
+ (text, size, bold) => {
42
+ context.font = `${bold ? 700 : 400} ${size}px Inter`;
43
+ return context.measureText(text).width;
44
+ },
45
+ );
46
+ ```
47
+
48
+ Supply measurements for the actual font used by your renderer. Explicit line breaks are preserved. Break opportunities follow UAX #14 through [linebreak](https://github.com/foliojs/linebreak) (Unicode 13); oversized tokens use grapheme boundaries where `Intl.Segmenter` is available. A grapheme wider than the available line fails explicitly. Tiny trailing lines are balanced without adding lines; pass `{ balance: false }` as the sixth argument to disable this. Titles may opt into `{ keepWords: true, preferClauseBreaks: true }` to respect available word segmentation and a fitting topic prefix before a colon. Dictionary segmentation is language-dependent and is not a semantic guarantee. Hyphenation, font shaping and cross-device font installation remain the renderer’s responsibility. Returned lines are plain text; escape them before inserting them into HTML.
49
+
50
+ ## Add custom scenes
51
+
52
+ ```ts
53
+ import { createSceneRegistry } from "@neatico/visual-core";
54
+
55
+ type Brief = { headline: string };
56
+ type Scene = { text: string; accent: string };
57
+ const scenes = createSceneRegistry<Brief, Scene>();
58
+ const unregister = scenes.register("product-launch", (brief) => ({
59
+ text: brief.headline,
60
+ accent: "#6950e8",
61
+ }));
62
+ const scene = scenes.render("product-launch", {
63
+ headline: "Meet your next idea.",
64
+ });
65
+ unregister();
66
+ ```
67
+
68
+ Registries are isolated instances. Duplicate names and unknown scenes throw errors. Renderers own their input validation and output format, so you can return your own canvas schema, SVG tree, or other representation. Async renderers are supported by choosing a promise as the output type. No global registry is created.
69
+
70
+ ## Development
71
+
72
+ ```sh
73
+ npm ci
74
+ npm test
75
+ npm pack --dry-run
76
+ ```
77
+
78
+ Tests cover transform equivalence, invalid geometry, text wrapping, and extension isolation. `npm pack` runs the build and tests before assembling the package. Only compiled output, this README, and the license are published.
79
+
80
+ ## Contributing
81
+
82
+ Open an issue or pull request at [neatico/visual-core](https://github.com/neatico/visual-core). Include a minimal reproduction and a regression test. Keep APIs independent of application state and use American English in documentation and code comments.
83
+
84
+ ## License
85
+
86
+ MIT. See [LICENSE](./LICENSE).
87
+
88
+ ## Portable template blueprints
89
+
90
+ `validateBlueprint` accepts a versioned, data-only template with an `id`, localized
91
+ `labels` (including `en` as the fallback), required `fields`, supported `ratios`,
92
+ and a `composition`. Every required field must be bound exactly once. The
93
+ composition validator returns only supported structural properties; executable
94
+ markup and arbitrary style properties are not part of this contract.
95
+
96
+ ```ts
97
+ import { createBlueprintRegistry, blueprintLabel } from '@neatico/visual-core';
98
+
99
+ const catalog = createBlueprintRegistry();
100
+ const unregister = catalog.register({
101
+ version: 1,
102
+ id: 'editorial-introduction',
103
+ labels: { en: 'Editorial introduction', zh: '编辑式导言' },
104
+ fields: ['title', 'body'],
105
+ ratios: [1, 16 / 9],
106
+ composition: {
107
+ version: 1,
108
+ root: { type: 'column', children: [
109
+ { type: 'text', bind: 'title', role: 'hero' },
110
+ { type: 'text', bind: 'body', role: 'body' },
111
+ ] },
112
+ },
113
+ });
114
+ const choices = catalog.find(['title', 'body'], 16 / 9);
115
+ const name = blueprintLabel(choices[0], 'zh-CN');
116
+ unregister();
117
+ ```
118
+
119
+ Registries are isolated, reject duplicate IDs, and return defensive copies.
120
+ Field matching is exact: a template cannot silently omit extra user content.
121
+ Locale lookup tries the exact locale, then its language, then English.
122
+
123
+ Structural validation is not visual approval. Before registering AI-authored
124
+ blueprints in a production catalog, the host must render representative samples
125
+ at every supported ratio with the actual font metrics and check bounds, text
126
+ collisions, readability, and content preservation. The host also owns persistence,
127
+ workspace permissions, review status, and migration between blueprint versions.
128
+
129
+ ## Content-aware layout eligibility (0.6)
130
+
131
+ `LAYOUT_CONTRACTS` covers all 97 slide and 97 social IDs with semantic intent,
132
+ item capacity, required fields and ratio bounds. `eligibleLayouts` filters the
133
+ catalog, and `layoutCandidates` returns a bounded deterministic shortlist.
134
+ These are structural eligibility checks, not a guarantee that arbitrary copy
135
+ fits. Hosts must measure emitted geometry, verify all source fields remain
136
+ visible and reject or paginate unreadable output. No text is rewritten here.
137
+
138
+ Blueprints may declare `intents` (0.8.0), such as `['cover']` for presentation layouts or `['features']` for social scenes. Registry lookup accepts the semantic intent as its third argument and excludes scoped templates when the intent is absent or incompatible. Omit this property only for deliberately generic layouts. This keeps identical field shapes from turning a cover-only layout into a closing slide or unrelated social format.
@@ -0,0 +1,20 @@
1
+ import { type Composition } from './composition.js';
2
+ /** Portable AI-authored template. Applications own approval and visual checks. */
3
+ export interface Blueprint {
4
+ version: 1;
5
+ id: string;
6
+ labels: Record<string, string>;
7
+ fields: string[];
8
+ ratios: number[];
9
+ /** Optional semantic layout/scene IDs. Omit only for intentionally generic layouts. */
10
+ intents?: string[];
11
+ composition: Composition;
12
+ }
13
+ export declare function validateBlueprint(raw: unknown): Blueprint;
14
+ /** Register only after the application has approved representative rendered samples. */
15
+ export declare function createBlueprintRegistry(): {
16
+ register(raw: unknown): () => void;
17
+ list(): Blueprint[];
18
+ find(fields: string[], ratio: number, intent?: string): Blueprint[];
19
+ };
20
+ export declare function blueprintLabel(blueprint: Blueprint, locale: string): string;
@@ -0,0 +1,43 @@
1
+ import { validateComposition } from './composition.js';
2
+ export function validateBlueprint(raw) {
3
+ const value = raw;
4
+ if (!value || value.version !== 1 || typeof value.id !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/.test(value.id))
5
+ throw new Error('Invalid blueprint identity');
6
+ if (!value.labels || typeof value.labels !== 'object' || Array.isArray(value.labels) || !value.labels.en)
7
+ throw new Error('Missing blueprint labels');
8
+ const labels = {};
9
+ for (const [locale, label] of Object.entries(value.labels)) {
10
+ if (!/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(locale) || typeof label !== 'string' || !label.trim() || label.length > 120)
11
+ throw new Error('Invalid blueprint label');
12
+ labels[locale] = label.trim();
13
+ }
14
+ if (!Array.isArray(value.fields) || !value.fields.length || value.fields.length > 40 || new Set(value.fields).size !== value.fields.length || value.fields.some(field => typeof field !== 'string' || !/^(title|headline|body|eyebrow|cta|value|attribution|items\.\d{1,2}\.(title|description))$/.test(field)))
15
+ throw new Error('Invalid blueprint fields');
16
+ if (!Array.isArray(value.ratios) || !value.ratios.length || value.ratios.length > 16 || value.ratios.some(r => !Number.isFinite(r) || r < .25 || r > 4))
17
+ throw new Error('Invalid blueprint ratios');
18
+ if (value.intents !== undefined && (!Array.isArray(value.intents) || !value.intents.length || value.intents.length > 32 || value.intents.some(intent => typeof intent !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/.test(intent))))
19
+ throw new Error('Invalid blueprint intents');
20
+ return { version: 1, id: value.id, labels, ...(value.intents ? { intents: [...new Set(value.intents)] } : {}), fields: [...value.fields], ratios: [...new Set(value.ratios)], composition: validateComposition(value.composition, Object.fromEntries(value.fields.map(field => [field, 'content']))) };
21
+ }
22
+ /** Register only after the application has approved representative rendered samples. */
23
+ export function createBlueprintRegistry() {
24
+ const entries = new Map();
25
+ const copy = (entry) => validateBlueprint(entry);
26
+ return {
27
+ register(raw) {
28
+ const entry = validateBlueprint(raw);
29
+ if (entries.has(entry.id))
30
+ throw new Error('Blueprint already registered');
31
+ entries.set(entry.id, entry);
32
+ return () => { if (entries.get(entry.id) === entry)
33
+ entries.delete(entry.id); };
34
+ },
35
+ list() { return [...entries.values()].map(copy); },
36
+ find(fields, ratio, intent) {
37
+ return [...entries.values()].filter(entry => (!entry.intents || (intent !== undefined && entry.intents.includes(intent))) && entry.fields.length === fields.length && entry.fields.every(field => fields.includes(field)) && entry.ratios.some(r => Math.abs(r - ratio) < .001)).map(copy);
38
+ },
39
+ };
40
+ }
41
+ export function blueprintLabel(blueprint, locale) {
42
+ return blueprint.labels[locale] || blueprint.labels[locale.split('-')[0]] || blueprint.labels.en;
43
+ }
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateBlueprint = validateBlueprint;
4
+ exports.createBlueprintRegistry = createBlueprintRegistry;
5
+ exports.blueprintLabel = blueprintLabel;
6
+ const composition_js_1 = require("./composition.js");
7
+ function validateBlueprint(raw) {
8
+ const value = raw;
9
+ if (!value || value.version !== 1 || typeof value.id !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/.test(value.id))
10
+ throw new Error('Invalid blueprint identity');
11
+ if (!value.labels || typeof value.labels !== 'object' || Array.isArray(value.labels) || !value.labels.en)
12
+ throw new Error('Missing blueprint labels');
13
+ const labels = {};
14
+ for (const [locale, label] of Object.entries(value.labels)) {
15
+ if (!/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(locale) || typeof label !== 'string' || !label.trim() || label.length > 120)
16
+ throw new Error('Invalid blueprint label');
17
+ labels[locale] = label.trim();
18
+ }
19
+ if (!Array.isArray(value.fields) || !value.fields.length || value.fields.length > 40 || new Set(value.fields).size !== value.fields.length || value.fields.some(field => typeof field !== 'string' || !/^(title|headline|body|eyebrow|cta|value|attribution|items\.\d{1,2}\.(title|description))$/.test(field)))
20
+ throw new Error('Invalid blueprint fields');
21
+ if (!Array.isArray(value.ratios) || !value.ratios.length || value.ratios.length > 16 || value.ratios.some(r => !Number.isFinite(r) || r < .25 || r > 4))
22
+ throw new Error('Invalid blueprint ratios');
23
+ if (value.intents !== undefined && (!Array.isArray(value.intents) || !value.intents.length || value.intents.length > 32 || value.intents.some(intent => typeof intent !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/.test(intent))))
24
+ throw new Error('Invalid blueprint intents');
25
+ return { version: 1, id: value.id, labels, ...(value.intents ? { intents: [...new Set(value.intents)] } : {}), fields: [...value.fields], ratios: [...new Set(value.ratios)], composition: (0, composition_js_1.validateComposition)(value.composition, Object.fromEntries(value.fields.map(field => [field, 'content']))) };
26
+ }
27
+ /** Register only after the application has approved representative rendered samples. */
28
+ function createBlueprintRegistry() {
29
+ const entries = new Map();
30
+ const copy = (entry) => validateBlueprint(entry);
31
+ return {
32
+ register(raw) {
33
+ const entry = validateBlueprint(raw);
34
+ if (entries.has(entry.id))
35
+ throw new Error('Blueprint already registered');
36
+ entries.set(entry.id, entry);
37
+ return () => { if (entries.get(entry.id) === entry)
38
+ entries.delete(entry.id); };
39
+ },
40
+ list() { return [...entries.values()].map(copy); },
41
+ find(fields, ratio, intent) {
42
+ return [...entries.values()].filter(entry => (!entry.intents || (intent !== undefined && entry.intents.includes(intent))) && entry.fields.length === fields.length && entry.fields.every(field => fields.includes(field)) && entry.ratios.some(r => Math.abs(r - ratio) < .001)).map(copy);
43
+ },
44
+ };
45
+ }
46
+ function blueprintLabel(blueprint, locale) {
47
+ return blueprint.labels[locale] || blueprint.labels[locale.split('-')[0]] || blueprint.labels.en;
48
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COMPOSITION_PROMPT = void 0;
4
+ exports.compositionFields = compositionFields;
5
+ exports.validateComposition = validateComposition;
6
+ exports.COMPOSITION_PROMPT = `Also return composition:{version:1,root:node}. Design a hierarchy from the content, not a fixed template. A node is {type,weight?,tone?,role?,bind?,icon?,children?}. row and column contain 2-4 children; nest up to 5 levels, at most 40 nodes. Weight is relative space 0.5-4. Tone plain/card/contrast applies to groups. Text binds exactly one existing nonempty content field (title or headline, body, eyebrow, cta, value, attribution, items.0.title, items.0.description, etc); never bind notes. Include EVERY nonempty content field exactly once. Text role hero/heading/body/caption controls typography. Image is an optional original conceptual illustration, not invented evidence or a photo. Icon optionally uses idea/check/chart/people/shield/arrow. Divider separates related content. No coordinates, CSS, HTML, URLs or text in the tree. Use one strong hero, purposeful contrast, asymmetric columns, grouped evidence, and breathing room. Rows reflow for narrow formats. Different stories should have different trees, not just different colors.`;
7
+ function compositionFields(value) {
8
+ const fields = {};
9
+ for (const key of ['title', 'headline', 'body', 'eyebrow', 'cta', 'value', 'attribution'])
10
+ if (typeof value[key] === 'string' && value[key].trim())
11
+ fields[key] = value[key];
12
+ if (Array.isArray(value.items))
13
+ value.items.forEach((item, i) => { if (!item || typeof item !== 'object')
14
+ return; for (const key of ['title', 'description']) {
15
+ if (!Object.prototype.hasOwnProperty.call(item, key))
16
+ continue;
17
+ const v = item[key];
18
+ if (typeof v === 'string' && v.trim())
19
+ fields[`items.${i}.${key}`] = v;
20
+ } });
21
+ return fields;
22
+ }
23
+ function validateComposition(raw, fields) {
24
+ const value = raw;
25
+ let count = 0, images = 0;
26
+ const seen = new Set();
27
+ if (!value || value.version !== 1)
28
+ throw new Error('Unsupported composition');
29
+ function visit(n, depth) {
30
+ if (!n || typeof n !== 'object' || ++count > 40 || depth > 5 || !['row', 'column', 'text', 'image', 'divider', 'icon'].includes(n.type))
31
+ throw new Error('Invalid composition node');
32
+ if (n.weight !== undefined && (!Number.isFinite(n.weight) || n.weight < .5 || n.weight > 4))
33
+ throw new Error('Invalid composition weight');
34
+ if (n.tone !== undefined && !['plain', 'card', 'contrast'].includes(n.tone))
35
+ throw new Error('Invalid composition tone');
36
+ const out = { type: n.type, weight: n.weight, tone: n.tone };
37
+ if (n.type === 'row' || n.type === 'column') {
38
+ if (!Array.isArray(n.children) || n.children.length < 2 || n.children.length > 4)
39
+ throw new Error('Invalid composition group');
40
+ out.children = n.children.map(c => visit(c, depth + 1));
41
+ }
42
+ else {
43
+ if (n.children !== undefined)
44
+ throw new Error('Leaf cannot have children');
45
+ if (n.type === 'text') {
46
+ if (!n.bind || !Object.prototype.hasOwnProperty.call(fields, n.bind) || seen.has(n.bind))
47
+ throw new Error('Invalid or duplicated binding');
48
+ if (n.role !== undefined && !['hero', 'heading', 'body', 'caption'].includes(n.role))
49
+ throw new Error('Invalid text role');
50
+ seen.add(n.bind);
51
+ out.bind = n.bind;
52
+ out.role = n.role || 'body';
53
+ }
54
+ if (n.type === 'image' && ++images > 1)
55
+ throw new Error('Only one illustration per composition');
56
+ if (n.type === 'icon') {
57
+ if (!n.icon || !['idea', 'check', 'chart', 'people', 'shield', 'arrow'].includes(n.icon))
58
+ throw new Error('Invalid icon');
59
+ out.icon = n.icon;
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ const root = visit(value.root, 0);
65
+ if (Object.keys(fields).some(key => !seen.has(key)))
66
+ throw new Error('Composition omits content');
67
+ return { version: 1, root };
68
+ }
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AUTHORED_COMPOSITIONS = exports.COMPOSITION_SCENES = void 0;
4
+ exports.sceneRecipePreference = sceneRecipePreference;
5
+ const both = ['presentation', 'social'];
6
+ exports.COMPOSITION_SCENES = [
7
+ { id: 'brand-collection', kinds: ['features'], outputs: both },
8
+ { id: 'hero', kinds: ['paragraph'], outputs: both }, { id: 'chapter', kinds: ['paragraph'], outputs: ['presentation'] },
9
+ { id: 'statement', kinds: ['paragraph'], outputs: both }, { id: 'closing', kinds: ['paragraph'], outputs: both },
10
+ { id: 'product-launch', kinds: ['features'], outputs: both }, { id: 'feature-spotlight', kinds: ['features'], outputs: both },
11
+ { id: 'feature-overview', kinds: ['features'], outputs: both }, { id: 'benefits', kinds: ['features'], outputs: both },
12
+ { id: 'comparison', kinds: ['comparison'], outputs: both }, { id: 'before-after', kinds: ['comparison'], outputs: both },
13
+ { id: 'plan-options', kinds: ['comparison'], outputs: both }, { id: 'process', kinds: ['steps'], outputs: both },
14
+ { id: 'roadmap', kinds: ['steps'], outputs: both }, { id: 'tutorial', kinds: ['steps'], outputs: both },
15
+ { id: 'checklist', kinds: ['features'], outputs: both }, { id: 'metrics', kinds: ['chart'], outputs: both },
16
+ { id: 'trend', kinds: ['chart'], outputs: both }, { id: 'results', kinds: ['chart'], outputs: both },
17
+ { id: 'quotation', kinds: ['quote'], outputs: both }, { id: 'testimonial', kinds: ['quote'], outputs: both },
18
+ { id: 'code-example', kinds: ['code'], outputs: both }, { id: 'specifications', kinds: ['parameters'], outputs: both },
19
+ { id: 'pricing-details', kinds: ['parameters'], outputs: both }, { id: 'event-details', kinds: ['parameters'], outputs: both },
20
+ { id: 'recruitment-details', kinds: ['parameters'], outputs: both },
21
+ { id: 'product-demonstration', kinds: ['image'], outputs: both, requiresAsset: true },
22
+ { id: 'photograph', kinds: ['image'], outputs: both, requiresAsset: true },
23
+ ...['banner-hero', 'product-showcase', 'editorial-cover', 'image-campaign'].map(id => ({ id, kinds: ['image'], outputs: both, requiresAsset: true })),
24
+ ];
25
+ exports.AUTHORED_COMPOSITIONS = [
26
+ { id: 'feature-focus', kind: 'features', min: 1, max: 1 },
27
+ { id: 'feature-bento', kind: 'features', min: 2, max: 6 },
28
+ { id: 'feature-index', kind: 'features', min: 2, max: 6 },
29
+ { id: 'steps-journey', kind: 'steps', min: 2, max: 6 },
30
+ { id: 'steps-ribbon', kind: 'steps', min: 2, max: 4 },
31
+ { id: 'comparison-panels', kind: 'comparison', min: 2, max: 3 },
32
+ { id: 'quote-editorial', kind: 'quote', min: 0, max: 0 },
33
+ { id: 'quote-poster', kind: 'quote', min: 0, max: 0 },
34
+ { id: 'code-editorial', kind: 'code', min: 0, max: 0 },
35
+ { id: 'parameter-ledger', kind: 'parameters', min: 1, max: 8 },
36
+ { id: 'parameter-cards', kind: 'parameters', min: 1, max: 4 },
37
+ ];
38
+ /** Soft art-direction preferences; capacity and measured quality always remain mandatory. */
39
+ function sceneRecipePreference(scene, recipeId) {
40
+ const preferred = {
41
+ 'banner-hero': ['authored-image-banner'], 'product-showcase': ['authored-image-split'], 'editorial-cover': ['authored-image-editorial'], 'image-campaign': ['authored-image-campaign'],
42
+ hero: ['cover', 'semantic-hero-split'], chapter: ['section', 'semantic-hero-editorial'], statement: ['statement', 'semantic-hero-stack'], closing: ['closing'],
43
+ 'product-launch': ['authored-feature-bento'], 'feature-spotlight': ['authored-feature-focus'], 'feature-overview': ['authored-feature-bento', 'three-column'], benefits: ['authored-feature-index'],
44
+ comparison: ['authored-comparison-panels'], 'before-after': ['before-after', 'semantic-comparison-split'], 'plan-options': ['authored-comparison-panels'],
45
+ process: ['authored-steps-ribbon'], roadmap: ['authored-steps-ribbon', 'roadmap'], tutorial: ['authored-steps-journey'], checklist: ['authored-feature-index', 'checklist'],
46
+ quotation: ['authored-quote-editorial'], testimonial: ['authored-quote-poster'],
47
+ 'code-example': ['authored-code-editorial'], specifications: ['authored-parameter-ledger'],
48
+ 'pricing-details': ['authored-parameter-cards'], 'event-details': ['authored-parameter-cards'], 'recruitment-details': ['authored-parameter-ledger'],
49
+ };
50
+ return scene && preferred[scene]?.some(id => recipeId === id || recipeId.startsWith(`${id}--`)) ? 6 : 0;
51
+ }