@neatico/visual-core 0.14.2 → 0.14.26

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.
@@ -1,119 +1,505 @@
1
- import { AUTHORED_COMPOSITIONS, sceneRecipePreference } from './compositionScenes.js';
2
- import { PRESENTATION_LAYOUT_IDS } from './layouts.js';
3
- import { SOCIAL_LAYOUT_CATALOG } from './socialLayouts.js';
4
- import { contentBlockText } from './content.js';
1
+ import { AUTHORED_COMPOSITIONS, sceneRecipePreference, } from "./compositionScenes.js";
2
+ import { SOCIAL_LAYOUT_CATALOG } from "./socialLayouts.js";
3
+ import { contentBlockText, } from "./content.js";
5
4
  /** Complete, deterministic token sets; decorative geometry belongs to individual compositions. */
6
5
  export const DESIGN_THEMES = [
7
- { id: 'studio', palette: { background: '#F5F3EE', surface: '#FFFFFF', ink: '#20251F', muted: '#5F685C', accent: '#376346', line: '#D7DED3' }, typography: { heading: 'Inter', body: 'Inter', code: 'monospace', headingWeight: 700 }, geometry: { radius: 4, spacing: 1, rule: 'hairline' } },
8
- { id: 'ink', palette: { background: '#121B29', surface: '#1D2B3E', ink: '#F5F7FB', muted: '#B3C2D6', accent: '#85C8F3', line: '#3D526C' }, typography: { heading: 'Inter', body: 'Inter', code: 'monospace', headingWeight: 700 }, geometry: { radius: 12, spacing: 1, rule: 'accent' } },
9
- { id: 'editorial', palette: { background: '#FCF8F1', surface: '#FFFFFF', ink: '#30281F', muted: '#756657', accent: '#A74328', line: '#DED2C1' }, typography: { heading: 'Georgia', body: 'Inter', code: 'monospace', headingWeight: 700 }, geometry: { radius: 0, spacing: 1.1, rule: 'hairline' } },
10
- { id: 'electric', palette: { background: '#F3F4FF', surface: '#FFFFFF', ink: '#222047', muted: '#615D7B', accent: '#5340D9', line: '#DAD5F2' }, typography: { heading: 'Inter', body: 'Inter', code: 'monospace', headingWeight: 800 }, geometry: { radius: 16, spacing: 1, rule: 'none' } },
11
- { id: 'signal', palette: { background: '#FFE04B', surface: '#FFF5B7', ink: '#242418', muted: '#535039', accent: '#272B21', line: '#B8A233' }, typography: { heading: 'Inter', body: 'Inter', code: 'monospace', headingWeight: 800 }, geometry: { radius: 2, spacing: 1.05, rule: 'none' } },
6
+ {
7
+ id: "neatico",
8
+ palette: { background: "#FAF9F6", surface: "#FFFFFF", ink: "#202124", muted: "#62646B", accent: "#F4CE36", line: "#E4E1D9" },
9
+ typography: { heading: "Inter", body: "Inter", code: "monospace", headingWeight: 700 },
10
+ geometry: { radius: 12, spacing: 1, rule: "accent" },
11
+ },
12
+ {
13
+ id: "studio",
14
+ palette: {
15
+ background: "#F5F3EE",
16
+ surface: "#FFFFFF",
17
+ ink: "#20251F",
18
+ muted: "#5F685C",
19
+ accent: "#376346",
20
+ line: "#D7DED3",
21
+ },
22
+ typography: {
23
+ heading: "Inter",
24
+ body: "Inter",
25
+ code: "monospace",
26
+ headingWeight: 700,
27
+ },
28
+ geometry: { radius: 4, spacing: 1, rule: "hairline" },
29
+ },
30
+ {
31
+ id: "ink",
32
+ palette: {
33
+ background: "#121B29",
34
+ surface: "#1D2B3E",
35
+ ink: "#F5F7FB",
36
+ muted: "#B3C2D6",
37
+ accent: "#85C8F3",
38
+ line: "#3D526C",
39
+ },
40
+ typography: {
41
+ heading: "Inter",
42
+ body: "Inter",
43
+ code: "monospace",
44
+ headingWeight: 700,
45
+ },
46
+ geometry: { radius: 12, spacing: 1, rule: "accent" },
47
+ },
48
+ {
49
+ id: "editorial",
50
+ palette: {
51
+ background: "#FCF8F1",
52
+ surface: "#FFFFFF",
53
+ ink: "#30281F",
54
+ muted: "#756657",
55
+ accent: "#A74328",
56
+ line: "#DED2C1",
57
+ },
58
+ typography: {
59
+ heading: "Lora",
60
+ body: "Inter",
61
+ code: "monospace",
62
+ headingWeight: 700,
63
+ },
64
+ geometry: { radius: 0, spacing: 1.1, rule: "hairline" },
65
+ },
66
+ {
67
+ id: "electric",
68
+ palette: {
69
+ background: "#F3F4FF",
70
+ surface: "#FFFFFF",
71
+ ink: "#222047",
72
+ muted: "#615D7B",
73
+ accent: "#5340D9",
74
+ line: "#DAD5F2",
75
+ },
76
+ typography: {
77
+ heading: "Inter",
78
+ body: "Inter",
79
+ code: "monospace",
80
+ headingWeight: 800,
81
+ },
82
+ geometry: { radius: 16, spacing: 1, rule: "none" },
83
+ },
84
+ {
85
+ id: "signal",
86
+ palette: {
87
+ background: "#FFE04B",
88
+ surface: "#FFF5B7",
89
+ ink: "#242418",
90
+ muted: "#535039",
91
+ accent: "#272B21",
92
+ line: "#B8A233",
93
+ },
94
+ typography: {
95
+ heading: "Inter",
96
+ body: "Inter",
97
+ code: "monospace",
98
+ headingWeight: 800,
99
+ },
100
+ geometry: { radius: 2, spacing: 1.05, rule: "none" },
101
+ },
12
102
  ];
13
103
  export function designTheme(id) {
14
- const alias = { neatico: 'electric', paper: 'studio', midnight: 'ink', ocean: 'ink', plum: 'electric' };
15
- return DESIGN_THEMES.find(theme => theme.id === (alias[id] || id)) || DESIGN_THEMES[0];
104
+ const alias = {
105
+ paper: "studio",
106
+ midnight: "ink",
107
+ ocean: "ink",
108
+ plum: "electric",
109
+ };
110
+ return (DESIGN_THEMES.find((theme) => theme.id === (alias[id] || id)) ||
111
+ DESIGN_THEMES[0]);
16
112
  }
17
113
  const family = (id, semantic) => {
18
114
  if (/code|terminal/.test(id))
19
- return 'code';
115
+ return "code";
20
116
  if (/comparison|compare|before-after|myth|problem-solution/.test(id))
21
- return 'comparison';
117
+ return "comparison";
22
118
  if (/process|steps|timeline|roadmap|journey|syllabus|workflow/.test(id))
23
- return 'steps';
119
+ return "steps";
24
120
  if (/chart|metric|stat|score|usage-breakdown/.test(id))
25
- return 'chart';
121
+ return "chart";
26
122
  if (/quote/.test(id))
27
- return 'quote';
123
+ return "quote";
28
124
  if (/table|specification/.test(id))
29
- return 'parameters';
125
+ return "parameters";
30
126
  if (/image|photo|gallery/.test(id))
31
- return 'image';
127
+ return "image";
32
128
  if (/cover|hero|banner|statement|closing|section/.test(id))
33
- return 'hero';
34
- if (semantic === 'steps')
35
- return 'steps';
36
- if (semantic === 'comparison')
37
- return 'comparison';
38
- return 'features';
129
+ return "hero";
130
+ if (semantic === "steps")
131
+ return "steps";
132
+ if (semantic === "comparison")
133
+ return "comparison";
134
+ return "features";
39
135
  };
40
- const accepted = (family) => ({ code: ['code'], comparison: ['comparison'], steps: ['steps'], chart: ['chart'], quote: ['quote'], parameters: ['parameters'], image: ['image'], hero: ['paragraph'], prose: ['paragraph'], features: ['features', 'paragraph'] }[family]);
136
+ const accepted = (family) => ({
137
+ code: ["code"],
138
+ comparison: ["comparison"],
139
+ steps: ["steps"],
140
+ chart: ["chart"],
141
+ quote: ["quote"],
142
+ parameters: ["parameters"],
143
+ image: ["image"],
144
+ hero: ["paragraph", "image"],
145
+ prose: ["paragraph"],
146
+ features: ["features", "paragraph"],
147
+ })[family];
41
148
  // Explicit semantic contracts, not name/scene guesses. A travel itinerary, calendar,
42
149
  // price promotion or fake UI cannot be inferred from an arbitrary paragraph.
43
150
  const socialContracts = {
44
- 'news-focus': ['paragraph'], manifesto: ['paragraph'], editorial: ['paragraph'], notice: ['paragraph'],
45
- 'block-banner': ['paragraph'], 'definition-card': ['paragraph'], 'concept-explainer': ['paragraph'],
46
- 'og-cover': ['paragraph'], 'gradient-cover': ['paragraph'], 'bold-diagonal-cover': ['paragraph'], 'split-cover': ['paragraph'],
47
- 'article-summary-card': ['paragraph'], 'paragraph-summary-card': ['paragraph'],
48
- bento: ['features'], spotlight: ['features'], report: ['features'], checklist: ['features'],
49
- 'contents-index': ['features'], 'contents-grid': ['features'], 'tips-card': ['features'],
50
- 'digest-card': ['features'], 'tips-grid': ['features'], 'feature-chip-hero': ['features'],
51
- steps: ['steps'], 'contents-chapters': ['steps'], 'illustration-process': ['steps'],
52
- comparison: ['comparison'], 'before-after': ['comparison'],
53
- 'code-snippet': ['code'], specification: ['parameters'],
54
- quote: ['quote'], 'editorial-quote': ['quote'], 'magazine-pull-quote': ['quote'],
151
+ "note-question": ["paragraph"], "note-marker": ["paragraph"], "note-editorial": ["paragraph"], "note-news": ["paragraph"], "note-checklist": ["features", "steps", "paragraph"], "note-photo": ["image", "paragraph"],
152
+ "news-focus": ["paragraph"],
153
+ manifesto: ["paragraph"],
154
+ editorial: ["paragraph"],
155
+ notice: ["paragraph"],
156
+ "block-banner": ["paragraph"],
157
+ "definition-card": ["paragraph"],
158
+ "concept-explainer": ["paragraph"],
159
+ "og-cover": ["paragraph"],
160
+ "gradient-cover": ["paragraph"],
161
+ "bold-diagonal-cover": ["paragraph"],
162
+ "split-cover": ["paragraph"],
163
+ "article-summary-card": ["paragraph"],
164
+ "paragraph-summary-card": ["paragraph"],
165
+ bento: ["features"],
166
+ spotlight: ["features"],
167
+ report: ["features"],
168
+ checklist: ["features"],
169
+ "contents-index": ["features"],
170
+ "contents-grid": ["features"],
171
+ "tips-card": ["features"],
172
+ "digest-card": ["features"],
173
+ "tips-grid": ["features"],
174
+ "feature-chip-hero": ["features"],
175
+ steps: ["steps"],
176
+ "contents-chapters": ["steps"],
177
+ "illustration-process": ["steps"],
178
+ comparison: ["comparison"],
179
+ "before-after": ["comparison"],
180
+ "code-snippet": ["code"],
181
+ specification: ["parameters"],
182
+ course: ["steps"],
183
+ "case-study": ["features"],
184
+ safety: ["features"],
185
+ faq: ["features"],
186
+ testimonial: ["quote"],
187
+ quote: ["quote"],
188
+ "editorial-quote": ["quote"],
189
+ "magazine-pull-quote": ["quote"],
190
+ };
191
+ /** Domain compositions require explicit semantic intent, never a keyword guess. */
192
+ const socialScenes = {
193
+ course: ["tutorial", "roadmap"],
194
+ safety: ["checklist"],
195
+ faq: ["questions"],
196
+ "case-study": ["case-study"],
197
+ testimonial: ["testimonial"],
198
+ };
199
+ export const MANUAL_COMPOSITION_REASONS = {
200
+ launch: "Legacy artwork simulates product UI; use authored-feature-bento or authored-image-split with a resolved asset.",
201
+ metric: "Legacy value slot lacks a chart-to-value binding; use typed chart compositions.",
202
+ myth: "Fixed wrong/right semantics cannot be inferred from ordinary comparison columns.",
203
+ story: "Legacy artwork is illustrative; use authored-image-editorial with a resolved image.",
204
+ team: "Requires typed people and corresponding portrait assets.",
205
+ "split-bar-compare": "Requires paired measured series; generic comparison text is insufficient.",
206
+ "benchmark-bars": "Requires explicit benchmark values, not invented percentages.",
207
+ "usage-breakdown": "Requires typed parts and a validated total; current chart blocks support bar/line measurements, not inferred percentage shares.",
208
+ organization: "Requires an explicit parent/child relationship contract.",
209
+ "research-highlight": "Requires methodology and limitations mapped independently.",
210
+ "product-showcase": "Use authored-image-split/campaign with a resolved image, not legacy fake UI.",
211
+ };
212
+ const parameterScenes = {
213
+ "time-notice": ["event-details"],
214
+ ticket: ["event-details"],
215
+ webinar: ["event-details"],
216
+ community: ["event-details"],
217
+ schedule: ["event-details"],
218
+ pricing: ["pricing-details"],
219
+ offer: ["offer-details"],
220
+ hiring: ["recruitment-details"],
221
+ menu: ["menu-details"],
222
+ itinerary: ["travel-details"],
55
223
  };
56
224
  // Automatic presentation matching requires an explicit semantic contract too.
57
225
  // A feature paragraph must never become a price plan, employee or risk entry.
58
- const presentationContracts = {
59
- cover: ['paragraph'], section: ['paragraph'], statement: ['paragraph'], closing: ['paragraph'],
60
- agenda: ['features'], bullets: ['features'], 'two-column': ['features'],
61
- 'three-column': ['features'], cards: ['features'], checklist: ['features'],
62
- process: ['steps'], timeline: ['steps'], roadmap: ['steps'], 'progress-roadmap': ['steps'],
63
- comparison: ['comparison'], 'before-after': ['comparison'], 'problem-solution': ['comparison'],
64
- metrics: ['chart'], 'bar-chart': ['chart'], 'usage-breakdown': ['chart'],
65
- quote: ['quote'], 'editorial-quote': ['quote'], 'magazine-pull-quote': ['quote'],
66
- table: ['parameters'], specification: ['parameters'], 'code-snippet': ['code'],
67
- 'image-left': ['image'], 'image-right': ['image'], 'image-full': ['image'],
68
- 'product-showcase': ['image'],
69
- };
70
226
  const recipe = (id, kind, semantic) => {
71
- const contract = (kind === 'social' ? socialContracts : presentationContracts)[id];
227
+ if (parameterScenes[id])
228
+ return {
229
+ id,
230
+ kind,
231
+ renderer: "semantic",
232
+ automatic: true,
233
+ family: `domain-${id}`,
234
+ accepts: ["parameters"],
235
+ minEntries: 1,
236
+ maxEntries: 6,
237
+ requiresImage: false,
238
+ scenes: parameterScenes[id],
239
+ };
240
+ const contract = socialContracts[id];
72
241
  const group = contract?.[0] || family(id, semantic);
73
- return { id, kind, renderer: 'catalog', automatic: Boolean(contract), family: group, accepts: contract || accepted(group), minEntries: group === 'comparison' ? 2 : kind === 'social' && (group === 'features' || group === 'steps' || group === 'parameters') ? 1 : 0, maxEntries: group === 'hero' ? 0 : kind === 'presentation' ? 4 : 6, requiresImage: group === 'image' };
242
+ return {
243
+ id,
244
+ kind,
245
+ renderer: "catalog",
246
+ automatic: Boolean(contract),
247
+ scenes: socialScenes[id],
248
+ ...(!contract
249
+ ? {
250
+ manualReason: MANUAL_COMPOSITION_REASONS[id] ||
251
+ "No verified semantic projection.",
252
+ }
253
+ : {}),
254
+ family: group,
255
+ accepts: contract || accepted(group),
256
+ minEntries: group === "comparison"
257
+ ? 2
258
+ : kind === "social" &&
259
+ (group === "features" ||
260
+ group === "steps" ||
261
+ group === "parameters")
262
+ ? 1
263
+ : 0,
264
+ maxEntries: group === "hero" ? 0 : kind === "presentation" ? 4 : 6,
265
+ requiresImage: group === "image",
266
+ };
74
267
  };
268
+ /** Stable aliases keep saved documents readable while excluding duplicate candidates. */
269
+ export function canonicalRecipeId(id, kind) {
270
+ if (/^semantic-(code|parameters|comparison|chart)-editorial$/.test(id))
271
+ return canonicalRecipeId(id.replace(/-editorial$/, "-stack"), kind);
272
+ if (id === "semantic-code-stack")
273
+ return "authored-code-editorial";
274
+ if (id === "semantic-comparison-stack")
275
+ return "authored-comparison-panels";
276
+ if (kind === "social" && id === "semantic-hero-split")
277
+ return "semantic-hero-editorial";
278
+ if (id === "semantic-quote-stack")
279
+ return "authored-quote-poster";
280
+ if (id === "semantic-quote-editorial")
281
+ return "authored-quote-editorial";
282
+ if (kind === "presentation" && id === "semantic-hero-split")
283
+ return "bookend-cover-split";
284
+ if (kind === "presentation" &&
285
+ ["semantic-hero-stack", "semantic-hero-editorial"].includes(id))
286
+ return "bookend-cover-editorial";
287
+ if (kind === "social" && id === "semantic-hero-stack")
288
+ return "semantic-hero-editorial";
289
+ return id;
290
+ }
75
291
  /** Existing authored catalogs remain first-class candidates, rather than being discarded. */
76
- export const DESIGN_RECIPES = [
77
- ...['cover', 'closing'].flatMap(role => ['editorial', 'panel', 'split'].map(style => ({ id: `bookend-${role}-${style}`, kind: 'presentation', renderer: 'semantic', family: `${role}-${style}`, accepts: ['paragraph'], minEntries: 0, maxEntries: 0, requiresImage: false }))),
78
- ...['presentation', 'social'].map(kind => ({ id: 'brand-collection-grid', kind, renderer: 'semantic', family: 'brand-collection', accepts: ['features'], minEntries: 2, maxEntries: 12, requiresImage: false })),
79
- ...PRESENTATION_LAYOUT_IDS.map(id => recipe(id, 'presentation')),
80
- ...SOCIAL_LAYOUT_CATALOG.map(item => recipe(item.id, 'social', item.scene)),
81
- ...['presentation', 'social'].flatMap(kind => [
82
- ...['banner', 'split', 'editorial', 'campaign', 'inset'].map(style => ({ id: `authored-image-${style}`, kind, renderer: 'semantic', family: `image-${style}`, accepts: ['image'], minEntries: 0, maxEntries: 0, requiresImage: true })),
83
- { id: 'authored-prose-inset', kind, renderer: 'semantic', family: 'prose-inset', accepts: ['paragraph'], minEntries: 0, maxEntries: 0, requiresImage: false },
84
- ...AUTHORED_COMPOSITIONS.map(composition => ({ id: `authored-${composition.id}`, kind, renderer: 'semantic', family: composition.id, accepts: [composition.kind], minEntries: composition.min, maxEntries: composition.max, requiresImage: false })),
85
- ...['code', 'parameters', 'steps', 'comparison', 'features', 'quote', 'chart', 'hero', 'prose'].flatMap(group => ['split', 'stack', 'editorial'].map(variant => ({ id: `semantic-${group}-${variant}`, kind, renderer: 'semantic', family: `${group}-${variant}`, accepts: accepted(group), minEntries: 0, maxEntries: 20, requiresImage: false }))),
86
- { id: 'semantic-mixed-stack', kind, renderer: 'semantic', family: 'mixed-stack', accepts: ['paragraph', 'steps', 'features', 'comparison', 'code', 'parameters', 'quote', 'chart', 'image'], minEntries: 0, maxEntries: 20, requiresImage: false },
292
+ const ALL_DESIGN_RECIPES = [
293
+ ...["cover", "closing"].flatMap((role) => ["editorial", "panel", "split"].map((style) => ({
294
+ id: `bookend-${role}-${style}`,
295
+ kind: "presentation",
296
+ renderer: "semantic",
297
+ family: `${role}-${style}`,
298
+ accepts: ["paragraph", "image"],
299
+ minEntries: 0,
300
+ maxEntries: 0,
301
+ requiresImage: false,
302
+ }))),
303
+ ...["presentation", "social"].map((kind) => ({
304
+ id: "brand-collection-grid",
305
+ kind,
306
+ renderer: "semantic",
307
+ family: "brand-collection",
308
+ accepts: ["features"],
309
+ minEntries: 2,
310
+ maxEntries: 12,
311
+ requiresImage: false,
312
+ })),
313
+ ...SOCIAL_LAYOUT_CATALOG.map((item) => recipe(item.id, "social", item.scene)),
314
+ ...["presentation", "social"].flatMap((kind) => [
315
+ {
316
+ id: "authored-plan-cards",
317
+ kind,
318
+ renderer: "semantic",
319
+ family: "plan-cards",
320
+ accepts: ["comparison", "paragraph"],
321
+ minEntries: 2,
322
+ maxEntries: 4,
323
+ requiresImage: false,
324
+ },
325
+ ...["banner", "split", "editorial", "campaign", "inset"].map((style) => ({
326
+ id: `authored-image-${style}`,
327
+ kind,
328
+ renderer: "semantic",
329
+ family: `image-${style}`,
330
+ accepts: ["image", "paragraph", "features"],
331
+ minEntries: 0,
332
+ maxEntries: 3,
333
+ requiresImage: true,
334
+ })),
335
+ {
336
+ id: "authored-prose-inset",
337
+ kind,
338
+ renderer: "semantic",
339
+ family: "prose-inset",
340
+ accepts: ["paragraph"],
341
+ minEntries: 0,
342
+ maxEntries: 0,
343
+ requiresImage: false,
344
+ },
345
+ ...AUTHORED_COMPOSITIONS.map((composition) => ({
346
+ id: `authored-${composition.id}`,
347
+ kind,
348
+ renderer: "semantic",
349
+ family: composition.id,
350
+ accepts: [composition.kind, "paragraph"],
351
+ minEntries: composition.min,
352
+ maxEntries: composition.max,
353
+ requiresImage: false,
354
+ })),
355
+ ...[
356
+ "code",
357
+ "parameters",
358
+ "steps",
359
+ "comparison",
360
+ "features",
361
+ "quote",
362
+ "chart",
363
+ "hero",
364
+ "prose",
365
+ ].flatMap((group) => (["code", "parameters", "comparison", "chart"].includes(group)
366
+ ? ["split", "stack"]
367
+ : ["split", "stack", "editorial"]).map((variant) => ({
368
+ id: `semantic-${group}-${variant}`,
369
+ kind,
370
+ renderer: "semantic",
371
+ family: `${group}-${variant}`,
372
+ accepts: group === "prose" && variant === "editorial"
373
+ ? ["paragraph", "image"]
374
+ : accepted(group),
375
+ minEntries: 0,
376
+ maxEntries: 20,
377
+ requiresImage: false,
378
+ }))),
379
+ {
380
+ id: "semantic-mixed-stack",
381
+ kind,
382
+ renderer: "semantic",
383
+ family: "mixed-stack",
384
+ accepts: [
385
+ "paragraph",
386
+ "steps",
387
+ "features",
388
+ "comparison",
389
+ "code",
390
+ "parameters",
391
+ "quote",
392
+ "chart",
393
+ "image",
394
+ ],
395
+ minEntries: 0,
396
+ maxEntries: 20,
397
+ requiresImage: false,
398
+ },
87
399
  ]),
88
400
  ];
401
+ export const DESIGN_RECIPES = ALL_DESIGN_RECIPES.filter((r) => canonicalRecipeId(r.id, r.kind) === r.id);
89
402
  export function pageEntryCount(page) {
90
- return page.blocks.reduce((sum, block) => sum + (block.kind === 'steps' || block.kind === 'features' ? block.entries.length : block.kind === 'comparison' ? block.columns.length : block.kind === 'parameters' ? block.rows.length : block.kind === 'chart' ? block.values.length : 0), 0);
403
+ return page.blocks.reduce((sum, block) => sum +
404
+ (block.kind === "steps" || block.kind === "features"
405
+ ? block.entries.length
406
+ : block.kind === "comparison"
407
+ ? block.columns.length
408
+ : block.kind === "parameters"
409
+ ? block.rows.length
410
+ : block.kind === "chart"
411
+ ? block.values.length
412
+ : 0), 0);
91
413
  }
92
414
  export function designCandidates(page, context) {
93
- if (!Number.isFinite(context.ratio) || context.ratio < .25 || context.ratio > 4)
415
+ if (!Number.isFinite(context.ratio) ||
416
+ context.ratio < 0.25 ||
417
+ context.ratio > 4)
94
418
  return [];
95
- const primary = page.blocks.filter(b => b.kind !== 'paragraph' && !(b.kind === 'image' && b.status === 'missing'));
96
- const kinds = [...new Set((primary.length ? primary : page.blocks.filter(b => b.kind !== 'image')).map(b => b.kind))];
97
- const count = pageEntryCount(page), characters = page.blocks.flatMap(contentBlockText).join('').length;
98
- const available = page.blocks.some(b => b.kind === 'image' && b.status === 'available');
419
+ const primary = page.blocks.filter((b) => b.kind !== "paragraph" && !(b.kind === "image" && b.status === "missing"));
420
+ const kinds = [
421
+ ...new Set((primary.length
422
+ ? primary
423
+ : page.blocks.filter((b) => b.kind !== "image")).map((b) => b.kind)),
424
+ ];
425
+ const richComparison = page.blocks.some((b) => b.kind === "comparison" &&
426
+ b.columns.some((c) => c.price || c.values || c.statuses));
427
+ const count = pageEntryCount(page), characters = page.blocks.flatMap(contentBlockText).join("").length;
428
+ const available = page.blocks.some((b) => b.kind === "image" && b.status === "available");
99
429
  const exclude = new Set(context.excluded || []);
100
- const candidates = DESIGN_RECIPES.filter(r => r.kind === context.kind && r.automatic !== false && (!(r.family.startsWith('hero') && page.purpose !== 'cover' && page.purpose !== 'conclude' && characters > 120)) && (!r.id.startsWith('bookend-') || (page.purpose === 'cover' ? r.id.startsWith('bookend-cover-') : page.purpose === 'conclude' && r.id.startsWith('bookend-closing-'))) && (r.id !== 'brand-collection-grid' || page.scene === 'brand-collection') && !exclude.has(r.id) && count >= r.minEntries && count <= r.maxEntries && (!r.requiresImage || available) && kinds.every(k => r.accepts.includes(k)) && !(r.renderer === 'catalog' && (primary.length > 1 || characters > 1000)));
430
+ const candidates = DESIGN_RECIPES.filter((r) => r.kind === context.kind &&
431
+ r.automatic !== false &&
432
+ (!r.scenes || Boolean(page.scene && r.scenes.includes(page.scene))) &&
433
+ !(r.family.startsWith("hero") &&
434
+ page.purpose !== "cover" &&
435
+ page.purpose !== "conclude" &&
436
+ characters > 120) &&
437
+ (!r.id.startsWith("bookend-") ||
438
+ (page.purpose === "cover"
439
+ ? r.id.startsWith("bookend-cover-")
440
+ : page.purpose === "conclude" &&
441
+ r.id.startsWith("bookend-closing-"))) &&
442
+ (r.id !== "brand-collection-grid" || page.scene === "brand-collection") &&
443
+ !exclude.has(r.id) &&
444
+ count >= r.minEntries &&
445
+ count <= r.maxEntries &&
446
+ (!r.requiresImage || available) &&
447
+ kinds.every((k) => r.accepts.includes(k)) &&
448
+ !(r.renderer === "catalog" &&
449
+ (richComparison || primary.length > 1 || characters > 1000)));
101
450
  const score = (r) => {
102
- const repeats = (context.previousFamilies || []).filter(f => f === r.family).length;
103
- const intent = page.purpose === 'cover' || page.purpose === 'conclude' ? r.family.startsWith('hero') : page.purpose === 'instruct' ? r.family.startsWith('steps') || r.family.startsWith('code') : true;
104
- return (r.id.startsWith('bookend-') ? 80 : 0) + (page.scene === 'brand-collection' && r.id === 'brand-collection-grid' ? 80 : 0) + sceneRecipePreference(page.scene, r.id) + (intent ? 30 : 0) + (r.renderer === 'catalog' ? 8 : 0) - (r.id.includes('mixed') ? 20 : 0) - repeats * 18 + (context.ratio < 1 && r.id.endsWith('stack') ? 6 : 0);
451
+ const repeats = (context.previousFamilies || []).filter((f) => f === r.family).length;
452
+ const intent = page.purpose === "cover" || page.purpose === "conclude"
453
+ ? r.family.startsWith("hero")
454
+ : page.purpose === "instruct"
455
+ ? r.family.startsWith("steps") || r.family.startsWith("code")
456
+ : true;
457
+ return ((r.id.startsWith("bookend-") ? 80 : 0) +
458
+ (page.scene === "brand-collection" && r.id === "brand-collection-grid"
459
+ ? 80
460
+ : 0) +
461
+ sceneRecipePreference(page.scene, r.id) +
462
+ (intent ? 30 : 0) +
463
+ (r.renderer === "catalog" ? 8 : 0) -
464
+ (r.id.includes("mixed") ? 20 : 0) -
465
+ repeats * 18 +
466
+ (context.ratio < 1 && r.id.endsWith("stack") ? 6 : 0));
105
467
  };
106
- const ranked = candidates.map((r, index) => ({ r, index, score: score(r) })).sort((a, b) => b.score - a.score || a.index - b.index).map(e => e.r);
468
+ const ranked = candidates
469
+ .map((r, index) => ({ r, index, score: score(r) }))
470
+ .sort((a, b) => b.score - a.score || a.index - b.index)
471
+ .map((e) => e.r);
107
472
  const limit = Math.max(1, Math.min(32, context.limit ?? 12));
108
473
  // Catalog size must not prevent semantic renderers from reaching measurement.
109
- const semantic = ranked.filter(r => r.renderer === 'semantic').slice(0, Math.min(8, Math.ceil(limit / 2)));
110
- const reserved = new Set(semantic.map(r => r.id));
111
- const selected = new Set([...semantic, ...ranked.filter(r => !reserved.has(r.id)).slice(0, limit - semantic.length)].map(r => r.id));
112
- return ranked.filter(r => selected.has(r.id));
474
+ const semantic = ranked
475
+ .filter((r) => r.renderer === "semantic")
476
+ .slice(0, Math.min(8, Math.ceil(limit / 2)));
477
+ const reserved = new Set(semantic.map((r) => r.id));
478
+ const selected = new Set([
479
+ ...semantic,
480
+ ...ranked
481
+ .filter((r) => !reserved.has(r.id))
482
+ .slice(0, limit - semantic.length),
483
+ ].map((r) => r.id));
484
+ return ranked.filter((r) => selected.has(r.id));
113
485
  }
114
486
  /** Ignores color, copy and generated IDs; previews that only recolor are the same composition. */
115
487
  export function structureFingerprint(items, width, height) {
116
- if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
117
- throw new Error('INVALID_CANVAS');
118
- return items.map(item => [item.role, ...[item.x / width, item.y / height, item.width / width, item.height / height].map(v => Math.round(v * 24))].join(':')).sort().join('|');
488
+ if (!Number.isFinite(width) ||
489
+ !Number.isFinite(height) ||
490
+ width <= 0 ||
491
+ height <= 0)
492
+ throw new Error("INVALID_CANVAS");
493
+ return items
494
+ .map((item) => [
495
+ item.role,
496
+ ...[
497
+ item.x / width,
498
+ item.y / height,
499
+ item.width / width,
500
+ item.height / height,
501
+ ].map((v) => Math.round(v * 24)),
502
+ ].join(":"))
503
+ .sort()
504
+ .join("|");
119
505
  }
@@ -1,4 +1,4 @@
1
- import { type ContentPage, type ContentPlan } from './content.js';
1
+ import { type ContentPage, type ContentPlan } from "./content.js";
2
2
  /** Reading density, not just geometric fit. CJK occupies roughly twice Latin's width. */
3
3
  export declare const readingUnits: (text: string) => number;
4
4
  export declare function needsEditorialPagination(page: ContentPage): boolean;