| 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 | |
| 7 | export const LAYOUT_MASTER_CATEGORIES = [ |
| 8 | 'cover', |
| 9 | 'content', |
| 10 | 'comparison', |
| 11 | 'data', |
| 12 | 'narrative', |
| 13 | 'closing' |
| 14 | ] as const |
| 15 | |
| 16 | export type LayoutMasterCategory = (typeof LAYOUT_MASTER_CATEGORIES)[number] |
| 17 | |
| 18 | export type LayoutMasterTemplate = { |
| 19 | id: string |
| 20 | intent: LayoutIntent |
| 21 | category: LayoutMasterCategory |
| 22 | name: string |
| 23 | nameZh: string |
| 24 | description: string |
| 25 | descriptionZh: string |
| 26 | preview: |
| 27 | | 'title-center' |
| 28 | | 'title-split' |
| 29 | | 'editorial' |
| 30 | | 'two-column' |
| 31 | | 'metric-grid' |
| 32 | | 'chart-side' |
| 33 | | 'versus' |
| 34 | | 'timeline' |
| 35 | | 'process' |
| 36 | | 'quote' |
| 37 | | 'image-focus' |
| 38 | | 'closing' |
| 39 | prompt: string |
| 40 | } |
| 41 | |
| 42 | export type SessionLayoutLibrary = { |
| 43 | version: typeof MASTER_LAYOUTS_VERSION |
| 44 | mappings: Record<LayoutIntent, string> |
| 45 | } |
| 46 | |
| 47 | export type SessionLayoutLibraryStatus = { |
| 48 | library: SessionLayoutLibrary |
| 49 | exists: boolean |
| 50 | revision: string |
| 51 | } |
| 52 | |
| 53 | const LAYOUT_MASTER_TEMPLATES: LayoutMasterTemplate[] = [ |
| 54 | { |
| 55 | id: 'cover-statement', |
| 56 | intent: 'cover', |
| 57 | category: 'cover', |
| 58 | name: 'Statement cover', |
| 59 | nameZh: '主张式封面', |
| 60 | description: 'A single message with restrained supporting detail.', |
| 61 | descriptionZh: '单一核心主张,配合克制的辅助信息。', |
| 62 | preview: 'title-center', |
| 63 | prompt: |
| 64 | '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.' |
| 65 | }, |
| 66 | { |
| 67 | id: 'cover-split', |
| 68 | intent: 'cover', |
| 69 | category: 'cover', |
| 70 | name: 'Split cover', |
| 71 | nameZh: '左右分屏封面', |
| 72 | description: 'A clear title block balanced by one hero visual.', |
| 73 | descriptionZh: '清晰标题区与单个主视觉平衡构成。', |
| 74 | preview: 'title-split', |
| 75 | prompt: |
| 76 | '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.' |
| 77 | }, |
| 78 | { |
| 79 | id: 'content-editorial', |
| 80 | intent: 'concept', |
| 81 | category: 'content', |
| 82 | name: 'Editorial content', |
| 83 | nameZh: '编辑式内容页', |
| 84 | description: 'A title-led narrative with one clear reading path.', |
| 85 | descriptionZh: '标题主导的叙事内容,阅读路径明确。', |
| 86 | preview: 'editorial', |
| 87 | prompt: |
| 88 | '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.' |
| 89 | }, |
| 90 | { |
| 91 | id: 'content-two-column', |
| 92 | intent: 'concept', |
| 93 | category: 'content', |
| 94 | name: 'Two-column narrative', |
| 95 | nameZh: '双栏叙事页', |
| 96 | description: 'Two related content groups with intentional imbalance.', |
| 97 | descriptionZh: '两个相关内容组,以有意的不对称形成层级。', |
| 98 | preview: 'two-column', |
| 99 | prompt: |
| 100 | '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.' |
| 101 | }, |
| 102 | { |
| 103 | id: 'data-metrics', |
| 104 | intent: 'data-focus', |
| 105 | category: 'data', |
| 106 | name: 'Metric focus', |
| 107 | nameZh: '核心指标页', |
| 108 | description: 'A key number or chart supported by concise evidence.', |
| 109 | descriptionZh: '核心数字或图表主导,配合简洁证据。', |
| 110 | preview: 'metric-grid', |
| 111 | prompt: |
| 112 | 'Make one metric, trend, or chart the dominant visual anchor. Support it with no more than three concise evidence modules and make numeric hierarchy immediately scannable.' |
| 113 | }, |
| 114 | { |
| 115 | id: 'data-chart-side', |
| 116 | intent: 'data-focus', |
| 117 | category: 'data', |
| 118 | name: 'Chart with takeaway', |
| 119 | nameZh: '图表结论页', |
| 120 | description: 'A chart-led area paired with a decisive takeaway.', |
| 121 | descriptionZh: '图表主区域配合明确结论。', |
| 122 | preview: 'chart-side', |
| 123 | prompt: |
| 124 | '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.' |
| 125 | }, |
| 126 | { |
| 127 | id: 'comparison-versus', |
| 128 | intent: 'comparison', |
| 129 | category: 'comparison', |
| 130 | name: 'Versus comparison', |
| 131 | nameZh: '正反对比', |
| 132 | description: 'Two alternatives aligned against shared criteria.', |
| 133 | descriptionZh: '两个方案围绕共用维度对齐比较。', |
| 134 | preview: 'versus', |
| 135 | prompt: |
| 136 | '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.' |
| 137 | }, |
| 138 | { |
| 139 | id: 'comparison-matrix', |
| 140 | intent: 'comparison', |
| 141 | category: 'comparison', |
| 142 | name: 'Comparison matrix', |
| 143 | nameZh: '矩阵对比', |
| 144 | description: 'A compact shared-criteria comparison with one recommendation.', |
| 145 | descriptionZh: '围绕共用维度紧凑比较,并给出一个建议。', |
| 146 | preview: 'two-column', |
| 147 | prompt: |
| 148 | '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.' |
| 149 | }, |
| 150 | { |
| 151 | id: 'timeline-progress', |
| 152 | intent: 'timeline', |
| 153 | category: 'narrative', |
| 154 | name: 'Progress timeline', |
| 155 | nameZh: '进程时间线', |
| 156 | description: 'A sequence of stages with one highlighted moment.', |
| 157 | descriptionZh: '阶段推进的时间线,突出一个关键节点。', |
| 158 | preview: 'timeline', |
| 159 | prompt: |
| 160 | '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.' |
| 161 | }, |
| 162 | { |
| 163 | id: 'timeline-milestones', |
| 164 | intent: 'timeline', |
| 165 | category: 'narrative', |
| 166 | name: 'Milestone story', |
| 167 | nameZh: '里程碑叙事', |
| 168 | description: 'A milestone sequence with a strong present or future state.', |
| 169 | descriptionZh: '里程碑序列,突出当前或未来状态。', |
| 170 | preview: 'timeline', |
| 171 | prompt: |
| 172 | '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.' |
| 173 | }, |
| 174 | { |
| 175 | id: 'concept-hierarchy', |
| 176 | intent: 'concept', |
| 177 | category: 'content', |
| 178 | name: 'Concept hierarchy', |
| 179 | nameZh: '概念层级', |
| 180 | description: 'One central idea and grouped supporting concepts.', |
| 181 | descriptionZh: '一个中心概念,搭配分组的支撑信息。', |
| 182 | preview: 'process', |
| 183 | prompt: |
| 184 | '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.' |
| 185 | }, |
| 186 | { |
| 187 | id: 'process-flow', |
| 188 | intent: 'process', |
| 189 | category: 'narrative', |
| 190 | name: 'Flow process', |
| 191 | nameZh: '流程机制', |
| 192 | description: 'A directional flow with visible cause and effect.', |
| 193 | descriptionZh: '方向明确的流程,清楚表达因果关系。', |
| 194 | preview: 'process', |
| 195 | prompt: |
| 196 | '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.' |
| 197 | }, |
| 198 | { |
| 199 | id: 'process-cycle', |
| 200 | intent: 'process', |
| 201 | category: 'narrative', |
| 202 | name: 'Cycle process', |
| 203 | nameZh: '循环机制', |
| 204 | description: 'A recurring system with a deliberate feedback loop.', |
| 205 | descriptionZh: '循环系统,明确表现反馈关系。', |
| 206 | preview: 'process', |
| 207 | prompt: |
| 208 | '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.' |
| 209 | }, |
| 210 | { |
| 211 | id: 'summary-takeaway', |
| 212 | intent: 'summary', |
| 213 | category: 'closing', |
| 214 | name: 'Key takeaway', |
| 215 | nameZh: '结论总结', |
| 216 | description: 'One conclusion supported by compact proof points.', |
| 217 | descriptionZh: '一个结论,配合紧凑的支撑证据。', |
| 218 | preview: 'closing', |
| 219 | prompt: |
| 220 | '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.' |
| 221 | }, |
| 222 | { |
| 223 | id: 'summary-evidence', |
| 224 | intent: 'summary', |
| 225 | category: 'closing', |
| 226 | name: 'Evidence recap', |
| 227 | nameZh: '证据回顾', |
| 228 | description: 'A concise conclusion supported by a few memorable facts.', |
| 229 | descriptionZh: '简洁结论配合少量关键事实回顾。', |
| 230 | preview: 'closing', |
| 231 | prompt: |
| 232 | '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.' |
| 233 | }, |
| 234 | { |
| 235 | id: 'quote-focus', |
| 236 | intent: 'quote', |
| 237 | category: 'content', |
| 238 | name: 'Quote focus', |
| 239 | nameZh: '引言聚焦', |
| 240 | description: 'A statement-led composition with minimal context.', |
| 241 | descriptionZh: '语句主导的构图,只保留最少必要背景。', |
| 242 | preview: 'quote', |
| 243 | prompt: |
| 244 | '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.' |
| 245 | }, |
| 246 | { |
| 247 | id: 'quote-side-note', |
| 248 | intent: 'quote', |
| 249 | category: 'content', |
| 250 | name: 'Quote with context', |
| 251 | nameZh: '引言与注释', |
| 252 | description: 'A strong statement paired with a compact contextual note.', |
| 253 | descriptionZh: '重点语句配合紧凑的背景说明。', |
| 254 | preview: 'quote', |
| 255 | prompt: |
| 256 | '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.' |
| 257 | }, |
| 258 | { |
| 259 | id: 'image-spotlight', |
| 260 | intent: 'image-focus', |
| 261 | category: 'content', |
| 262 | name: 'Image spotlight', |
| 263 | nameZh: '视觉聚焦', |
| 264 | description: 'A dominant visual field with concise supporting copy.', |
| 265 | descriptionZh: '主视觉区域占主导,文字简洁辅助。', |
| 266 | preview: 'image-focus', |
| 267 | prompt: |
| 268 | '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.' |
| 269 | }, |
| 270 | { |
| 271 | id: 'image-caption', |
| 272 | intent: 'image-focus', |
| 273 | category: 'content', |
| 274 | name: 'Visual caption', |
| 275 | nameZh: '图片注释页', |
| 276 | description: 'A visual field supported by a structured caption block.', |
| 277 | descriptionZh: '视觉主区域配合有层级的说明文字。', |
| 278 | preview: 'image-focus', |
| 279 | prompt: |
| 280 | '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.' |
| 281 | } |
| 282 | ] |
| 283 | |
| 284 | const DEFAULT_LAYOUT_MAPPINGS: Record<LayoutIntent, string> = { |
| 285 | cover: 'cover-statement', |
| 286 | 'data-focus': 'data-metrics', |
| 287 | comparison: 'comparison-versus', |
| 288 | timeline: 'timeline-progress', |
| 289 | concept: 'content-editorial', |
| 290 | process: 'process-flow', |
| 291 | summary: 'summary-takeaway', |
| 292 | quote: 'quote-focus', |
| 293 | 'image-focus': 'image-spotlight' |
| 294 | } |
| 295 | |
| 296 | const TEMPLATE_BY_ID = new Map(LAYOUT_MASTER_TEMPLATES.map((template) => [template.id, template])) |
| 297 | |
| 298 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 299 | Boolean(value) && typeof value === 'object' && !Array.isArray(value) |
| 300 | |
| 301 | export const getLayoutMasterTemplates = (): LayoutMasterTemplate[] => |
| 302 | LAYOUT_MASTER_TEMPLATES.map((template) => ({ ...template })) |
| 303 | |
| 304 | export const getDefaultLayoutMasterMappings = (): Record<LayoutIntent, string> => ({ |
| 305 | ...DEFAULT_LAYOUT_MAPPINGS |
| 306 | }) |
| 307 | |
| 308 | export const getLayoutMasterTemplate = (value: unknown): LayoutMasterTemplate | null => { |
| 309 | const id = typeof value === 'string' ? value.trim() : '' |
| 310 | const template = TEMPLATE_BY_ID.get(id) |
| 311 | return template ? { ...template } : null |
| 312 | } |
| 313 | |
| 314 | export const buildDefaultSessionLayoutLibrary = (): SessionLayoutLibrary => ({ |
| 315 | version: MASTER_LAYOUTS_VERSION, |
| 316 | mappings: getDefaultLayoutMasterMappings() |
| 317 | }) |
| 318 | |
| 319 | export const normalizeSessionLayoutLibrary = (value: unknown): SessionLayoutLibrary => { |
| 320 | const input = isRecord(value) ? value : {} |
| 321 | const rawMappings = isRecord(input.mappings) ? input.mappings : {} |
| 322 | const mappings = getDefaultLayoutMasterMappings() |
| 323 | for (const intent of LAYOUT_INTENTS) { |
| 324 | const candidate = rawMappings[intent] |
| 325 | const template = getLayoutMasterTemplate(candidate) |
| 326 | if (template && template.intent === intent) mappings[intent] = template.id |
| 327 | } |
| 328 | return { version: MASTER_LAYOUTS_VERSION, mappings } |
| 329 | } |
| 330 | |
| 331 | export const isValidSessionLayoutLibrary = (value: unknown): value is SessionLayoutLibrary => { |
| 332 | if (!isRecord(value) || value.version !== MASTER_LAYOUTS_VERSION) return false |
| 333 | const mappings = value.mappings |
| 334 | if (!isRecord(mappings)) return false |
| 335 | return LAYOUT_INTENTS.every((intent) => { |
| 336 | const template = getLayoutMasterTemplate(mappings[intent]) |
| 337 | return template?.intent === intent |
| 338 | }) |
| 339 | } |
| 340 | |
| 341 | export const resolveLayoutMasterTemplate = ( |
| 342 | library: unknown, |
| 343 | intent: LayoutIntent | undefined |
| 344 | ): LayoutMasterTemplate => { |
| 345 | const normalizedIntent = normalizeLayoutIntent(intent) |
| 346 | const normalizedLibrary = normalizeSessionLayoutLibrary(library) |
| 347 | return ( |
| 348 | getLayoutMasterTemplate(normalizedLibrary.mappings[normalizedIntent]) || |
| 349 | getLayoutMasterTemplate(DEFAULT_LAYOUT_MAPPINGS[normalizedIntent]) || |
| 350 | getLayoutMasterTemplates()[0] |
| 351 | ) |
| 352 | } |
| 353 | |
| 354 | export const formatLayoutMasterPrompt = (template: LayoutMasterTemplate): string => |
| 355 | [ |
| 356 | `Selected layout master: ${template.name} (${template.id}).`, |
| 357 | `Composition contract: ${template.prompt}`, |
| 358 | 'Treat this as a flexible information architecture, not a pixel-for-pixel template. Keep the current style contract authoritative for visual language, and vary imagery, decoration, emphasis, and local composition to fit the content.' |
| 359 | ].join('\n') |
| 360 |