返回 oh-my-ppt
layout-master.ts
根目录 / src / shared / layout-master.ts
1 import { LAYOUT_INTENTS, normalizeLayoutIntent, type LayoutIntent } from './layout-intent'
2
3 export const MASTER_LAYOUTS_FILENAME = 'layouts.json'
4 export const MASTER_LAYOUTS_RELATIVE_PATH = `master/${MASTER_LAYOUTS_FILENAME}`
5 export const MASTER_LAYOUTS_VERSION = 1 as const
6 export const LAYOUT_CONTRACT_VERSION = 1 as const
7
8 export const LAYOUT_MASTER_CATEGORIES = [
9 'cover',
10 'content',
11 'comparison',
12 'data',
13 'narrative',
14 'closing'
15 ] as const
16
17 export type LayoutMasterCategory = (typeof LAYOUT_MASTER_CATEGORIES)[number]
18
19 export type LayoutSlotRole =
20 | 'title'
21 | 'subtitle'
22 | 'body'
23 | 'metric'
24 | 'chart'
25 | 'comparison'
26 | 'timeline'
27 | 'quote'
28 | 'takeaway'
29 | 'visual'
30 | 'source'
31
32 export type LayoutImagePolicy = 'forbidden' | 'optional' | 'preferred'
33
34 export type LayoutSlot = {
35 id: string
36 role: LayoutSlotRole
37 required: boolean
38 maxItems?: number
39 maxChars?: number
40 priority: 'hero' | 'support' | 'auxiliary'
41 image?: {
42 policy: LayoutImagePolicy
43 role: 'hero-image' | 'product-visual' | 'spot-illustration' | 'data-visual'
44 layer: 'background' | 'visual'
45 aspectHint?: string
46 }
47 }
48
49 export type PageLayoutSource = {
50 version: typeof LAYOUT_CONTRACT_VERSION
51 layoutId: string
52 layoutContractVersion: typeof LAYOUT_CONTRACT_VERSION
53 layoutIntent: LayoutIntent
54 }
55
56 export type LayoutMasterTemplate = {
57 id: string
58 intent: LayoutIntent
59 layoutContractVersion: typeof LAYOUT_CONTRACT_VERSION
60 slots: LayoutSlot[]
61 category: LayoutMasterCategory
62 name: string
63 nameZh: string
64 description: string
65 descriptionZh: string
66 preview:
67 | 'title-center'
68 | 'title-split'
69 | 'editorial'
70 | 'two-column'
71 | 'metric-grid'
72 | 'chart-side'
73 | 'versus'
74 | 'timeline'
75 | 'process'
76 | 'quote'
77 | 'image-focus'
78 | 'closing'
79 prompt: string
80 }
81
82 type LayoutMasterTemplateDefinition = Omit<
83 LayoutMasterTemplate,
84 'layoutContractVersion' | 'slots'
85 >
86
87 export type SessionLayoutLibrary = {
88 version: typeof MASTER_LAYOUTS_VERSION
89 mappings: Record<LayoutIntent, string>
90 }
91
92 export type SessionLayoutLibraryStatus = {
93 library: SessionLayoutLibrary
94 exists: boolean
95 revision: string
96 }
97
98 const createSlot = (
99 id: string,
100 role: LayoutSlotRole,
101 required: boolean,
102 priority: LayoutSlot['priority'],
103 options?: Pick<LayoutSlot, 'maxItems' | 'maxChars' | 'image'>
104 ): LayoutSlot => ({ id, role, required, priority, ...options })
105
106 const visualSlot = (
107 id: string,
108 required: boolean,
109 priority: LayoutSlot['priority'],
110 role: NonNullable<LayoutSlot['image']>['role'],
111 policy: LayoutImagePolicy,
112 aspectHint: string
113 ): LayoutSlot =>
114 createSlot(id, 'visual', required, priority, {
115 image: { role, policy, layer: 'visual', aspectHint }
116 })
117
118 const LAYOUT_MASTER_SLOTS: Record<string, LayoutSlot[]> = {
119 'cover-statement': [
120 createSlot('cover-title', 'title', true, 'hero', { maxChars: 80 }),
121 createSlot('cover-subtitle', 'subtitle', false, 'support', { maxChars: 180 }),
122 visualSlot('cover-visual', false, 'support', 'hero-image', 'preferred', '16:9')
123 ],
124 'cover-split': [
125 createSlot('cover-title', 'title', true, 'hero', { maxChars: 80 }),
126 createSlot('cover-context', 'subtitle', false, 'support', { maxChars: 180 }),
127 visualSlot('cover-visual', false, 'hero', 'hero-image', 'preferred', '4:3')
128 ],
129 'cover-immersive': [
130 createSlot('cover-title', 'title', true, 'hero', { maxChars: 80 }),
131 createSlot('cover-context', 'subtitle', false, 'support', { maxChars: 180 }),
132 visualSlot('cover-visual', false, 'hero', 'hero-image', 'preferred', '16:9')
133 ],
134 'content-editorial': [
135 createSlot('editorial-title', 'title', true, 'hero', { maxChars: 100 }),
136 createSlot('editorial-body', 'body', true, 'support', { maxChars: 600 }),
137 visualSlot('editorial-visual', false, 'support', 'spot-illustration', 'optional', '4:3')
138 ],
139 'content-two-column': [
140 createSlot('two-column-title', 'title', true, 'hero', { maxChars: 100 }),
141 createSlot('primary-column', 'body', true, 'hero', { maxChars: 500 }),
142 createSlot('supporting-column', 'body', true, 'support', { maxChars: 500 })
143 ],
144 'data-metrics': [
145 createSlot('metric-title', 'title', true, 'hero', { maxChars: 100 }),
146 createSlot('key-metric', 'metric', true, 'hero', { maxChars: 96 }),
147 createSlot('metric-evidence', 'body', false, 'support', { maxItems: 3 }),
148 visualSlot('metric-visual', false, 'support', 'spot-illustration', 'preferred', '1:1')
149 ],
150 'data-chart-side': [
151 createSlot('chart-title', 'title', true, 'hero', { maxChars: 100 }),
152 createSlot('primary-chart', 'chart', true, 'hero'),
153 createSlot('chart-takeaway', 'takeaway', true, 'support', { maxChars: 240 })
154 ],
155 'data-annotated': [
156 createSlot('chart-title', 'title', true, 'hero', { maxChars: 100 }),
157 createSlot('primary-chart', 'chart', true, 'hero'),
158 createSlot('chart-takeaway', 'takeaway', true, 'support', { maxChars: 240 })
159 ],
160 'comparison-versus': [
161 createSlot('comparison-title', 'title', true, 'hero', { maxChars: 100 }),
162 createSlot('alternatives', 'comparison', true, 'hero'),
163 createSlot('comparison-conclusion', 'takeaway', true, 'support', { maxChars: 220 })
164 ],
165 'comparison-matrix': [
166 createSlot('comparison-title', 'title', true, 'hero', { maxChars: 100 }),
167 createSlot('comparison-matrix', 'comparison', true, 'hero'),
168 createSlot('comparison-recommendation', 'takeaway', true, 'support', { maxChars: 220 })
169 ],
170 'comparison-decision': [
171 createSlot('comparison-title', 'title', true, 'hero', { maxChars: 100 }),
172 createSlot('alternatives', 'comparison', true, 'hero'),
173 createSlot('comparison-conclusion', 'takeaway', true, 'support', { maxChars: 220 })
174 ],
175 'timeline-progress': [
176 createSlot('timeline-title', 'title', true, 'hero', { maxChars: 100 }),
177 createSlot('timeline-stages', 'timeline', true, 'hero', { maxItems: 6 }),
178 createSlot('timeline-highlight', 'takeaway', false, 'support', { maxChars: 220 })
179 ],
180 'timeline-milestones': [
181 createSlot('timeline-title', 'title', true, 'hero', { maxChars: 100 }),
182 createSlot('milestones', 'timeline', true, 'hero', { maxItems: 6 }),
183 createSlot('current-state', 'takeaway', true, 'support', { maxChars: 220 })
184 ],
185 'timeline-journey': [
186 createSlot('timeline-title', 'title', true, 'hero', { maxChars: 100 }),
187 createSlot('milestones', 'timeline', true, 'hero', { maxItems: 6 }),
188 createSlot('current-state', 'takeaway', true, 'support', { maxChars: 220 })
189 ],
190 'concept-hierarchy': [
191 createSlot('concept-title', 'title', true, 'hero', { maxChars: 100 }),
192 createSlot('central-concept', 'body', true, 'hero', { maxChars: 220 }),
193 createSlot('supporting-concepts', 'body', true, 'support', { maxItems: 4 })
194 ],
195 'process-flow': [
196 createSlot('process-title', 'title', true, 'hero', { maxChars: 100 }),
197 createSlot('process-steps', 'timeline', true, 'hero', { maxItems: 6 }),
198 createSlot('process-outcome', 'takeaway', false, 'support', { maxChars: 220 })
199 ],
200 'process-cycle': [
201 createSlot('process-title', 'title', true, 'hero', { maxChars: 100 }),
202 createSlot('cycle-steps', 'timeline', true, 'hero', { maxItems: 6 }),
203 createSlot('cycle-insight', 'takeaway', false, 'support', { maxChars: 220 })
204 ],
205 'process-layers': [
206 createSlot('process-title', 'title', true, 'hero', { maxChars: 100 }),
207 createSlot('process-steps', 'timeline', true, 'hero', { maxItems: 6 }),
208 createSlot('process-outcome', 'takeaway', false, 'support', { maxChars: 220 })
209 ],
210 'summary-takeaway': [
211 createSlot('summary-title', 'title', true, 'hero', { maxChars: 100 }),
212 createSlot('primary-takeaway', 'takeaway', true, 'hero', { maxChars: 260 }),
213 createSlot('proof-points', 'body', false, 'support', { maxItems: 3 }),
214 visualSlot('summary-visual', false, 'support', 'spot-illustration', 'preferred', '4:3')
215 ],
216 'summary-evidence': [
217 createSlot('summary-title', 'title', true, 'hero', { maxChars: 100 }),
218 createSlot('summary-conclusion', 'takeaway', true, 'hero', { maxChars: 260 }),
219 createSlot('evidence-recap', 'body', true, 'support', { maxItems: 4 })
220 ],
221 'summary-argument': [
222 createSlot('summary-title', 'title', true, 'hero', { maxChars: 100 }),
223 createSlot('primary-takeaway', 'takeaway', true, 'hero', { maxChars: 260 }),
224 createSlot('proof-points', 'body', false, 'support', { maxItems: 3 }),
225 visualSlot('summary-visual', false, 'support', 'spot-illustration', 'preferred', '4:3')
226 ],
227 'quote-focus': [
228 createSlot('quote-statement', 'quote', true, 'hero', { maxChars: 320 }),
229 createSlot('quote-attribution', 'source', false, 'support', { maxChars: 120 })
230 ],
231 'quote-side-note': [
232 createSlot('quote-statement', 'quote', true, 'hero', { maxChars: 320 }),
233 createSlot('quote-context', 'body', true, 'support', { maxChars: 260 }),
234 createSlot('quote-source', 'source', false, 'auxiliary', { maxChars: 120 })
235 ],
236 'quote-editorial': [
237 createSlot('quote-statement', 'quote', true, 'hero', { maxChars: 320 }),
238 createSlot('quote-context', 'body', true, 'support', { maxChars: 260 }),
239 createSlot('quote-source', 'source', false, 'auxiliary', { maxChars: 120 })
240 ],
241 'image-spotlight': [
242 createSlot('image-title', 'title', true, 'hero', { maxChars: 100 }),
243 createSlot('image-supporting-copy', 'body', false, 'support', { maxChars: 280 }),
244 visualSlot('primary-visual', false, 'hero', 'hero-image', 'preferred', '16:9')
245 ],
246 'image-caption': [
247 createSlot('image-title', 'title', true, 'hero', { maxChars: 100 }),
248 visualSlot('primary-visual', false, 'hero', 'product-visual', 'preferred', '4:3'),
249 createSlot('visual-caption', 'body', true, 'support', { maxChars: 300 })
250 ],
251 'image-essay': [
252 createSlot('image-title', 'title', true, 'hero', { maxChars: 100 }),
253 visualSlot('primary-visual', false, 'hero', 'product-visual', 'preferred', '16:9'),
254 createSlot('visual-caption', 'body', true, 'support', { maxChars: 300 })
255 ]
256 }
257
258 const LAYOUT_MASTER_TEMPLATE_DEFINITIONS: LayoutMasterTemplateDefinition[] = [
259 {
260 id: 'cover-statement',
261 intent: 'cover',
262 category: 'cover',
263 name: 'Statement cover',
264 nameZh: '主张式封面',
265 description: 'A single message with restrained supporting detail.',
266 descriptionZh: '单一核心主张,配合克制的辅助信息。',
267 preview: 'title-center',
268 prompt:
269 'Use a single dominant title or claim with generous negative space. Keep supporting information small and grouped; give one visual or decorative anchor a clear secondary role.'
270 },
271 {
272 id: 'cover-split',
273 intent: 'cover',
274 category: 'cover',
275 name: 'Split cover',
276 nameZh: '左右分屏封面',
277 description: 'A clear title block balanced by one hero visual.',
278 descriptionZh: '清晰标题区与单个主视觉平衡构成。',
279 preview: 'title-split',
280 prompt:
281 'Use an asymmetric split composition: title and context occupy one side, while one hero visual or visual field occupies the other. Keep the title block compact and make the split deliberate.'
282 },
283 {
284 id: 'cover-immersive',
285 intent: 'cover',
286 category: 'cover',
287 name: 'Immersive cover',
288 nameZh: '沉浸式封面',
289 description: 'A title enters a full visual field with controlled context.',
290 descriptionZh: '标题进入完整视觉场,搭配克制的背景信息。',
291 preview: 'image-focus',
292 prompt:
293 'Let a title or claim enter a dominant visual field rather than sit in a separate panel. Use contrast, scale, crop, depth, and a small contextual detail to make the opening feel like a scene, object, or point of view.'
294 },
295 {
296 id: 'content-editorial',
297 intent: 'concept',
298 category: 'content',
299 name: 'Editorial content',
300 nameZh: '编辑式内容页',
301 description: 'A title-led narrative with one clear reading path.',
302 descriptionZh: '标题主导的叙事内容,阅读路径明确。',
303 preview: 'editorial',
304 prompt:
305 'Use an editorial composition: establish a strong title zone, one primary idea or visual anchor, and a small number of supporting modules. Preserve a clear top-to-bottom or left-to-right reading path.'
306 },
307 {
308 id: 'content-two-column',
309 intent: 'concept',
310 category: 'content',
311 name: 'Two-column narrative',
312 nameZh: '双栏叙事页',
313 description: 'Two related content groups with intentional imbalance.',
314 descriptionZh: '两个相关内容组,以有意的不对称形成层级。',
315 preview: 'two-column',
316 prompt:
317 'Use two related columns with intentional hierarchy rather than equal card stacks. Give one column a primary role and use the other for explanation, evidence, or a supporting visual.'
318 },
319 {
320 id: 'data-metrics',
321 intent: 'data-focus',
322 category: 'data',
323 name: 'Metric focus',
324 nameZh: '核心指标页',
325 description: 'A key number or chart supported by concise evidence.',
326 descriptionZh: '核心数字或图表主导,配合简洁证据。',
327 preview: 'metric-grid',
328 prompt:
329 'Make one metric, trend, or chart the dominant visual anchor. Support it with a small number of concise evidence modules and make numeric hierarchy immediately scannable. Choose the expression that best fits this page: a typographic hero beside a visual, a metric integrated into an annotated chart, a full-height evidence field, or a compact data band. Independent content modules must retain an actual nonzero gap. When evidence is brief, consider enlarging or repositioning the existing metric, chart, or evidence panels so they jointly carry the page height instead of pinning a shallow evidence rail directly beneath a chart.'
330 },
331 {
332 id: 'data-chart-side',
333 intent: 'data-focus',
334 category: 'data',
335 name: 'Chart with takeaway',
336 nameZh: '图表结论页',
337 description: 'A chart-led area paired with a decisive takeaway.',
338 descriptionZh: '图表主区域配合明确结论。',
339 preview: 'chart-side',
340 prompt:
341 'Allocate a substantial chart or data visualization area and pair it with one concise takeaway panel. Let the chart carry the evidence and keep surrounding labels restrained. The chart may lead as a large field, share the page with a tall conclusion, or become an annotated evidence surface; choose the relationship that best tells this page rather than repeating a fixed sidebar. Independent content modules must retain an actual nonzero gap. When the supporting content is short, let the chart or takeaway panel extend into the available height, or rebalance the existing modules vertically; avoid leaving an accidental empty lower band beneath a row of small cards.'
342 },
343 {
344 id: 'data-annotated',
345 intent: 'data-focus',
346 category: 'data',
347 name: 'Annotated evidence',
348 nameZh: '注释式数据证据',
349 description: 'One evidence surface with interpretation integrated into it.',
350 descriptionZh: '一个主证据面,将解释融入图表或数据视觉中。',
351 preview: 'chart-side',
352 prompt:
353 'Treat the chart, scale, or data object as the page itself: integrate the main statement, one or two annotations, and the conclusion into a single evidence surface. Use proximity and contrast to guide attention; supporting facts may orbit, attach to, or emerge from the visual rather than forming a conventional dashboard.'
354 },
355 {
356 id: 'comparison-versus',
357 intent: 'comparison',
358 category: 'comparison',
359 name: 'Versus comparison',
360 nameZh: '正反对比',
361 description: 'Two alternatives aligned against shared criteria.',
362 descriptionZh: '两个方案围绕共用维度对齐比较。',
363 preview: 'versus',
364 prompt:
365 'Use two clearly separated alternatives aligned against the same comparison criteria. Keep their visual weight balanced, make differences explicit, and reserve a short conclusion area.'
366 },
367 {
368 id: 'comparison-matrix',
369 intent: 'comparison',
370 category: 'comparison',
371 name: 'Comparison matrix',
372 nameZh: '矩阵对比',
373 description: 'A compact shared-criteria comparison with one recommendation.',
374 descriptionZh: '围绕共用维度紧凑比较,并给出一个建议。',
375 preview: 'two-column',
376 prompt:
377 'Use a compact comparison matrix or aligned criterion rows. Surface the most meaningful distinction visually, then close with one recommendation or implication rather than repeating every point.'
378 },
379 {
380 id: 'comparison-decision',
381 intent: 'comparison',
382 category: 'comparison',
383 name: 'Decision spine',
384 nameZh: '决策脊线对比',
385 description: 'Alternatives converge on one decisive distinction or choice.',
386 descriptionZh: '多个选项收束到一个决定性差异或选择。',
387 preview: 'versus',
388 prompt:
389 'Organize alternatives around the decisive choice rather than forcing equal side-by-side panels. A central verdict, threshold, criterion spine, or branching path can make the difference visible; give shared criteria and the final implication stronger structure than decorative symmetry.'
390 },
391 {
392 id: 'timeline-progress',
393 intent: 'timeline',
394 category: 'narrative',
395 name: 'Progress timeline',
396 nameZh: '进程时间线',
397 description: 'A sequence of stages with one highlighted moment.',
398 descriptionZh: '阶段推进的时间线,突出一个关键节点。',
399 preview: 'timeline',
400 prompt:
401 'Use a clear chronological progression with a limited number of stages. Emphasize the most important moment or transition and keep supporting detail attached to its stage.'
402 },
403 {
404 id: 'timeline-milestones',
405 intent: 'timeline',
406 category: 'narrative',
407 name: 'Milestone story',
408 nameZh: '里程碑叙事',
409 description: 'A milestone sequence with a strong present or future state.',
410 descriptionZh: '里程碑序列,突出当前或未来状态。',
411 preview: 'timeline',
412 prompt:
413 'Use a milestone sequence that leads clearly to a highlighted current, decision, or future state. Give the highlighted destination more space than the historical steps.'
414 },
415 {
416 id: 'timeline-journey',
417 intent: 'timeline',
418 category: 'narrative',
419 name: 'Journey timeline',
420 nameZh: '旅程式时间线',
421 description: 'A temporal path that turns stages into a visual journey.',
422 descriptionZh: '将阶段转化为一条具有空间感的时间旅程。',
423 preview: 'timeline',
424 prompt:
425 'Turn the sequence into a journey through the canvas: a route, rising path, staged landscape, or editorial progression can carry time. Keep the order unmistakable, but let scale, pause, and destination create drama instead of presenting identical stops on a single rail.'
426 },
427 {
428 id: 'concept-hierarchy',
429 intent: 'concept',
430 category: 'content',
431 name: 'Concept hierarchy',
432 nameZh: '概念层级',
433 description: 'One central idea and grouped supporting concepts.',
434 descriptionZh: '一个中心概念,搭配分组的支撑信息。',
435 preview: 'process',
436 prompt:
437 'Use one central concept or proposition with a small number of grouped supporting concepts. Make the hierarchy visible through scale and proximity, not a dense network of arrows.'
438 },
439 {
440 id: 'process-flow',
441 intent: 'process',
442 category: 'narrative',
443 name: 'Flow process',
444 nameZh: '流程机制',
445 description: 'A directional flow with visible cause and effect.',
446 descriptionZh: '方向明确的流程,清楚表达因果关系。',
447 preview: 'process',
448 prompt:
449 'Use a directional process or mechanism with visible handoffs between steps. Keep each stage concise and make the causal or operational flow legible at a glance.'
450 },
451 {
452 id: 'process-cycle',
453 intent: 'process',
454 category: 'narrative',
455 name: 'Cycle process',
456 nameZh: '循环机制',
457 description: 'A recurring system with a deliberate feedback loop.',
458 descriptionZh: '循环系统,明确表现反馈关系。',
459 preview: 'process',
460 prompt:
461 'Use a compact recurring cycle when the mechanism includes feedback or iteration. Make the loop legible, but keep labels and step count restrained so the process reads in one glance.'
462 },
463 {
464 id: 'process-layers',
465 intent: 'process',
466 category: 'narrative',
467 name: 'Layered mechanism',
468 nameZh: '分层机制图',
469 description: 'A process shown as stacked layers, handoffs, or operating planes.',
470 descriptionZh: '用分层、交接或运行平面表达流程机制。',
471 preview: 'process',
472 prompt:
473 'Show the mechanism as layers, swimlanes, handoffs, or an operating stack when that reveals how parts work together. Preserve causal order, while allowing steps to inhabit different visual planes instead of always becoming one left-to-right arrow chain.'
474 },
475 {
476 id: 'summary-takeaway',
477 intent: 'summary',
478 category: 'closing',
479 name: 'Key takeaway',
480 nameZh: '结论总结',
481 description: 'One conclusion supported by compact proof points.',
482 descriptionZh: '一个结论,配合紧凑的支撑证据。',
483 preview: 'closing',
484 prompt:
485 'Lead with one decisive conclusion. Use a compact set of supporting proof points or next actions beneath it, with the conclusion visually stronger than every support module.'
486 },
487 {
488 id: 'summary-evidence',
489 intent: 'summary',
490 category: 'closing',
491 name: 'Evidence recap',
492 nameZh: '证据回顾',
493 description: 'A concise conclusion supported by a few memorable facts.',
494 descriptionZh: '简洁结论配合少量关键事实回顾。',
495 preview: 'closing',
496 prompt:
497 'Use a concise conclusion with two to four memorable proof points. Let evidence appear as a compact recap, leaving enough negative space for the conclusion to remain dominant.'
498 },
499 {
500 id: 'summary-argument',
501 intent: 'summary',
502 category: 'closing',
503 name: 'Argument close',
504 nameZh: '论点式收束',
505 description: 'A final claim, proof, and visual memory point.',
506 descriptionZh: '以最终主张、证据和视觉记忆点完成收束。',
507 preview: 'closing',
508 prompt:
509 'Close as a short visual argument: state the final claim, give only the proof that earns it, and create one memorable visual or typographic memory point. It may feel like an editorial end card, a decisive poster, or a compact manifesto rather than a recap grid.'
510 },
511 {
512 id: 'quote-focus',
513 intent: 'quote',
514 category: 'content',
515 name: 'Quote focus',
516 nameZh: '引言聚焦',
517 description: 'A statement-led composition with minimal context.',
518 descriptionZh: '语句主导的构图,只保留最少必要背景。',
519 preview: 'quote',
520 prompt:
521 'Make the statement the visual anchor. Use an expressive type hierarchy and only minimal attribution or supporting context; do not dilute it with ordinary card grids.'
522 },
523 {
524 id: 'quote-side-note',
525 intent: 'quote',
526 category: 'content',
527 name: 'Quote with context',
528 nameZh: '引言与注释',
529 description: 'A strong statement paired with a compact contextual note.',
530 descriptionZh: '重点语句配合紧凑的背景说明。',
531 preview: 'quote',
532 prompt:
533 'Use a large statement zone paired with one compact context, source, or implication note. Maintain the statement as the visual anchor and keep the secondary note clearly subordinate.'
534 },
535 {
536 id: 'quote-editorial',
537 intent: 'quote',
538 category: 'content',
539 name: 'Editorial quote',
540 nameZh: '编辑式引言',
541 description: 'A statement staged through type, scale, and one supporting trace.',
542 descriptionZh: '用字体、尺度和一条支撑线索来呈现重点表达。',
543 preview: 'quote',
544 prompt:
545 'Stage the statement like an editorial moment. Let type scale, line breaks, framing space, and one trace of context or source make it felt before it is explained; the page can be quiet, dramatic, or documentary, but should not resemble a standard content card.'
546 },
547 {
548 id: 'image-spotlight',
549 intent: 'image-focus',
550 category: 'content',
551 name: 'Image spotlight',
552 nameZh: '视觉聚焦',
553 description: 'A dominant visual field with concise supporting copy.',
554 descriptionZh: '主视觉区域占主导,文字简洁辅助。',
555 preview: 'image-focus',
556 prompt:
557 'Give one image, product visual, or illustrated field dominant space. Keep text to a concise title and short supporting copy, positioned to complement rather than compete with the visual.'
558 },
559 {
560 id: 'image-caption',
561 intent: 'image-focus',
562 category: 'content',
563 name: 'Visual caption',
564 nameZh: '图片注释页',
565 description: 'A visual field supported by a structured caption block.',
566 descriptionZh: '视觉主区域配合有层级的说明文字。',
567 preview: 'image-focus',
568 prompt:
569 'Use a dominant visual field with a structured caption or annotation block. The supporting text should interpret the visual, not compete with it or turn into a generic card grid.'
570 },
571 {
572 id: 'image-essay',
573 intent: 'image-focus',
574 category: 'content',
575 name: 'Visual essay',
576 nameZh: '视觉短章',
577 description: 'A full visual field with a concise interpretive thread.',
578 descriptionZh: '完整视觉场配合简洁的解读线索。',
579 preview: 'image-focus',
580 prompt:
581 'Let the visual field establish the page mood and point of view, then place a concise title and interpretive thread where they create tension or dialogue with the image. Crop, scale, overlay, and annotation are expressive tools; the copy should read like a visual essay, not a caption card.'
582 }
583 ]
584
585 const cloneSlot = (slot: LayoutSlot): LayoutSlot => ({
586 ...slot,
587 image: slot.image ? { ...slot.image } : undefined
588 })
589
590 const cloneTemplate = (template: LayoutMasterTemplate): LayoutMasterTemplate => ({
591 ...template,
592 slots: template.slots.map(cloneSlot)
593 })
594
595 const LAYOUT_MASTER_TEMPLATES: LayoutMasterTemplate[] = LAYOUT_MASTER_TEMPLATE_DEFINITIONS.map(
596 (template) => ({
597 ...template,
598 layoutContractVersion: LAYOUT_CONTRACT_VERSION,
599 slots: (LAYOUT_MASTER_SLOTS[template.id] || []).map(cloneSlot)
600 })
601 )
602
603 const DEFAULT_LAYOUT_MAPPINGS: Record<LayoutIntent, string> = {
604 cover: 'cover-statement',
605 'data-focus': 'data-metrics',
606 comparison: 'comparison-versus',
607 timeline: 'timeline-progress',
608 concept: 'content-editorial',
609 process: 'process-flow',
610 summary: 'summary-takeaway',
611 quote: 'quote-focus',
612 'image-focus': 'image-spotlight'
613 }
614
615 const TEMPLATE_BY_ID = new Map(LAYOUT_MASTER_TEMPLATES.map((template) => [template.id, template]))
616
617 const isRecord = (value: unknown): value is Record<string, unknown> =>
618 Boolean(value) && typeof value === 'object' && !Array.isArray(value)
619
620 export const getLayoutMasterTemplates = (): LayoutMasterTemplate[] =>
621 LAYOUT_MASTER_TEMPLATES.map(cloneTemplate)
622
623 export const getDefaultLayoutMasterMappings = (): Record<LayoutIntent, string> => ({
624 ...DEFAULT_LAYOUT_MAPPINGS
625 })
626
627 export const getLayoutMasterTemplate = (value: unknown): LayoutMasterTemplate | null => {
628 const id = typeof value === 'string' ? value.trim() : ''
629 const template = TEMPLATE_BY_ID.get(id)
630 return template ? cloneTemplate(template) : null
631 }
632
633 export const createPageLayoutSource = (template: LayoutMasterTemplate): PageLayoutSource => ({
634 version: LAYOUT_CONTRACT_VERSION,
635 layoutId: template.id,
636 layoutContractVersion: template.layoutContractVersion,
637 layoutIntent: template.intent
638 })
639
640 export const resolvePageLayoutSourceTemplate = (
641 source: Pick<PageLayoutSource, 'layoutId' | 'layoutIntent'> | null | undefined
642 ): LayoutMasterTemplate | null => {
643 if (!source || !source.layoutId) return null
644 const template = getLayoutMasterTemplate(source.layoutId)
645 return template && template.intent === source.layoutIntent ? template : null
646 }
647
648 export const isCompatiblePageLayoutSource = (
649 source: PageLayoutSource | null | undefined
650 ): boolean => {
651 if (!source || source.version !== LAYOUT_CONTRACT_VERSION) return false
652 const template = resolvePageLayoutSourceTemplate(source)
653 return Boolean(template && template.layoutContractVersion === source.layoutContractVersion)
654 }
655
656 export const validateLayoutMasterTemplate = (template: LayoutMasterTemplate): string[] => {
657 const errors: string[] = []
658 const slotIds = new Set<string>()
659 for (const slot of template.slots) {
660 if (!slot.id.trim()) {
661 errors.push(`Layout ${template.id} has a slot without an id.`)
662 continue
663 }
664 if (slotIds.has(slot.id)) {
665 errors.push(`Layout ${template.id} has a duplicate slot id: ${slot.id}.`)
666 }
667 slotIds.add(slot.id)
668 if (slot.maxItems !== undefined && (!Number.isInteger(slot.maxItems) || slot.maxItems < 1)) {
669 errors.push(`Layout ${template.id} slot ${slot.id} has an invalid maxItems value.`)
670 }
671 if (slot.maxChars !== undefined && (!Number.isInteger(slot.maxChars) || slot.maxChars < 1)) {
672 errors.push(`Layout ${template.id} slot ${slot.id} has an invalid maxChars value.`)
673 }
674 if (slot.image && slot.role !== 'visual') {
675 errors.push(`Layout ${template.id} slot ${slot.id} declares image policy outside visual role.`)
676 }
677 }
678 if (!template.slots.some((slot) => slot.role === 'title' || slot.role === 'quote')) {
679 errors.push(`Layout ${template.id} must declare a title or quote slot.`)
680 }
681 return errors
682 }
683
684 export const buildDefaultSessionLayoutLibrary = (): SessionLayoutLibrary => ({
685 version: MASTER_LAYOUTS_VERSION,
686 mappings: getDefaultLayoutMasterMappings()
687 })
688
689 export const normalizeSessionLayoutLibrary = (value: unknown): SessionLayoutLibrary => {
690 const input = isRecord(value) ? value : {}
691 const rawMappings = isRecord(input.mappings) ? input.mappings : {}
692 const mappings = getDefaultLayoutMasterMappings()
693 for (const intent of LAYOUT_INTENTS) {
694 const candidate = rawMappings[intent]
695 const template = getLayoutMasterTemplate(candidate)
696 if (template && template.intent === intent) mappings[intent] = template.id
697 }
698 return { version: MASTER_LAYOUTS_VERSION, mappings }
699 }
700
701 export const isValidSessionLayoutLibrary = (value: unknown): value is SessionLayoutLibrary => {
702 if (!isRecord(value) || value.version !== MASTER_LAYOUTS_VERSION) return false
703 const mappings = value.mappings
704 if (!isRecord(mappings)) return false
705 return LAYOUT_INTENTS.every((intent) => {
706 const template = getLayoutMasterTemplate(mappings[intent])
707 return template?.intent === intent
708 })
709 }
710
711 export const resolveLayoutMasterTemplate = (
712 library: unknown,
713 intent: LayoutIntent | undefined
714 ): LayoutMasterTemplate => {
715 const normalizedIntent = normalizeLayoutIntent(intent)
716 const normalizedLibrary = normalizeSessionLayoutLibrary(library)
717 return (
718 getLayoutMasterTemplate(normalizedLibrary.mappings[normalizedIntent]) ||
719 getLayoutMasterTemplate(DEFAULT_LAYOUT_MAPPINGS[normalizedIntent]) ||
720 getLayoutMasterTemplates()[0]
721 )
722 }
723
724 /**
725 * Resolve a deterministic creative variant for a page that has not persisted a
726 * concrete layout yet. The session mapping remains the preferred first variant;
727 * later pages of the same intent can use the other catalog entries without
728 * changing the layout source of already-generated pages.
729 */
730 export const resolveLayoutMasterTemplateVariant = (
731 library: unknown,
732 intent: LayoutIntent | undefined,
733 variantIndex = 0
734 ): LayoutMasterTemplate => {
735 const normalizedIntent = normalizeLayoutIntent(intent)
736 const candidates = getLayoutMasterTemplates().filter(
737 (template) => template.intent === normalizedIntent
738 )
739 if (candidates.length === 0) return resolveLayoutMasterTemplate(library, normalizedIntent)
740
741 const preferred = resolveLayoutMasterTemplate(library, normalizedIntent)
742 const preferredIndex = Math.max(
743 0,
744 candidates.findIndex((template) => template.id === preferred.id)
745 )
746 const normalizedVariantIndex = Number.isInteger(variantIndex) && variantIndex >= 0 ? variantIndex : 0
747 return candidates[(preferredIndex + normalizedVariantIndex) % candidates.length]
748 }
749
750 export type StablePageLayoutResolution = {
751 layoutIntent: LayoutIntent
752 layoutId: string
753 layoutContractVersion: number
754 layoutPrompt: string
755 diagnostic?: 'layout-contract-incompatible'
756 }
757
758 /**
759 * A persisted page source is authoritative over the current session mapping.
760 * When a catalog entry has since disappeared, keep its identity instead of
761 * silently regenerating the page with a different layout.
762 */
763 export const resolveStablePageLayoutSource = (
764 library: unknown,
765 source: {
766 layoutIntent?: LayoutIntent | null
767 layoutId?: string | null
768 layoutContractVersion?: number | null
769 }
770 ): StablePageLayoutResolution => {
771 const layoutId = typeof source.layoutId === 'string' ? source.layoutId.trim() : ''
772 const layoutContractVersion = Number(source.layoutContractVersion)
773 const hasPersistedSource = Boolean(layoutId) && Number.isInteger(layoutContractVersion) && layoutContractVersion > 0
774 const layoutIntent = normalizeLayoutIntent(source.layoutIntent)
775
776 if (hasPersistedSource) {
777 const template = getLayoutMasterTemplate(layoutId)
778 if (template && (!source.layoutIntent || template.intent === layoutIntent)) {
779 return {
780 layoutIntent: source.layoutIntent || template.intent,
781 layoutId,
782 layoutContractVersion,
783 layoutPrompt: formatLayoutMasterPrompt(template)
784 }
785 }
786 return {
787 layoutIntent,
788 layoutId,
789 layoutContractVersion,
790 layoutPrompt:
791 `Stored layout source ${layoutId} is unavailable or incompatible. ` +
792 'Preserve the existing information architecture and do not remap this page to another layout.',
793 diagnostic: 'layout-contract-incompatible'
794 }
795 }
796
797 const template = resolveLayoutMasterTemplate(library, source.layoutIntent || undefined)
798 return {
799 layoutIntent: source.layoutIntent || template.intent,
800 layoutId: template.id,
801 layoutContractVersion: template.layoutContractVersion,
802 layoutPrompt: formatLayoutMasterPrompt(template)
803 }
804 }
805
806 export const formatLayoutMasterPrompt = (template: LayoutMasterTemplate): string =>
807 [
808 `Selected layout family: ${template.name} (${template.id}).`,
809 `Creative direction: ${template.prompt}`,
810 `Semantic anchors (layout compatibility v${template.layoutContractVersion}):`,
811 ...template.slots.map((slot) => {
812 const limits = [
813 slot.maxItems ? `target up to ${slot.maxItems} concise content items` : '',
814 slot.maxChars ? `target up to ${slot.maxChars} characters` : '',
815 slot.image ? `image policy: ${slot.image.policy}` : ''
816 ].filter(Boolean)
817 return `- ${slot.id}: ${slot.role}, ${slot.required ? 'required' : 'optional'}, ${slot.priority}${limits.length ? `, ${limits.join(', ')}` : ''}`
818 }),
819 'Mark every used anchor on its rendered element with data-ppt-slot. Anchors may use any semantic HTML structure that fits the composition. Character and item budgets are density guidance, not structural requirements.',
820 'Anchors name content roles, not coordinates or a mandatory grid. Recompose each use from the page thesis and current style: an asymmetric editorial field, centered hero, full-height split, layered annotation, spatial tension, or sequential path can all be valid. When nearby pages use the same family, choose a meaningfully different reading path or visual relationship instead of copying one memorized arrangement.',
821 'Use major zones to check visual rhythm, not to force equal boxes. Independent cards, charts, tables, and callouts must retain an actual nonzero gap. With sparse content, redistribute height among existing high-priority zones rather than pinning every module to its minimum height or inventing filler facts.',
822 'Treat this as a flexible information architecture, not a pixel-for-pixel template. Keep the current style contract authoritative for visual language, and let imagery, decoration, emphasis, and local composition create a distinct page.'
823 ].join('\n')
824
824 lines TYPESCRIPT