| 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 |