@cyber-dash-tech/revela 0.18.16 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +45 -31
  2. package/README.zh-CN.md +45 -31
  3. package/assets/img/lucent-01.jpg +0 -0
  4. package/assets/img/lucent-02.jpg +0 -0
  5. package/assets/img/lucent-03.jpg +0 -0
  6. package/assets/img/lucent-dark-01.jpg +0 -0
  7. package/assets/img/lucent-dark-02.jpg +0 -0
  8. package/assets/img/lucent-dark-03.jpg +0 -0
  9. package/assets/img/monet-01.jpg +0 -0
  10. package/assets/img/monet-02.jpg +0 -0
  11. package/assets/img/monet-03.jpg +0 -0
  12. package/assets/img/starter-01.jpg +0 -0
  13. package/assets/img/starter-02.jpg +0 -0
  14. package/assets/img/starter-03.jpg +0 -0
  15. package/assets/img/summit-01.jpg +0 -0
  16. package/assets/img/summit-02.jpg +0 -0
  17. package/assets/img/summit-03.jpg +0 -0
  18. package/designs/lucent/DESIGN.md +76 -0
  19. package/designs/lucent/design.css +283 -0
  20. package/designs/lucent-dark/DESIGN.md +278 -0
  21. package/designs/lucent-dark/assets/card-lens.jpg +0 -0
  22. package/designs/lucent-dark/assets/closing-background.jpg +0 -0
  23. package/designs/lucent-dark/assets/cover-background.jpg +0 -0
  24. package/designs/lucent-dark/assets/report-visual.jpg +0 -0
  25. package/designs/lucent-dark/assets/soft-texture.jpg +0 -0
  26. package/designs/lucent-dark/assets/toc-orb.png +0 -0
  27. package/designs/lucent-dark/design.css +417 -0
  28. package/designs/monet/DESIGN.md +14 -0
  29. package/designs/monet/assets/card-lens.jpg +0 -0
  30. package/designs/monet/assets/closing-background.jpg +0 -0
  31. package/designs/monet/assets/cover-background.jpg +0 -0
  32. package/designs/monet/assets/report-visual.jpg +0 -0
  33. package/designs/monet/assets/soft-texture.jpg +0 -0
  34. package/designs/monet/assets/toc-orb.png +0 -0
  35. package/designs/monet/design.css +340 -0
  36. package/designs/starter/DESIGN.md +14 -0
  37. package/designs/starter/assets/card-lens.jpg +0 -0
  38. package/designs/starter/assets/closing-background.jpg +0 -0
  39. package/designs/starter/assets/cover-background.jpg +0 -0
  40. package/designs/starter/assets/report-visual.jpg +0 -0
  41. package/designs/starter/assets/soft-texture.jpg +0 -0
  42. package/designs/starter/assets/toc-orb.png +0 -0
  43. package/designs/starter/design.css +322 -0
  44. package/designs/summit/DESIGN.md +18 -0
  45. package/designs/summit/assets/card-lens.jpg +0 -0
  46. package/designs/summit/assets/closing-background.jpg +0 -0
  47. package/designs/summit/assets/cover-background.jpg +0 -0
  48. package/designs/summit/assets/report-visual.jpg +0 -0
  49. package/designs/summit/assets/soft-texture.jpg +0 -0
  50. package/designs/summit/assets/toc-orb.png +0 -0
  51. package/designs/summit/design.css +334 -0
  52. package/lib/commands/designs-new.ts +13 -25
  53. package/lib/commands/designs-preview.ts +3 -8
  54. package/lib/deck-html/foundation.ts +8 -8
  55. package/lib/design/designs.ts +317 -14
  56. package/lib/narrative-state/deck-plan-artifact.ts +40 -3
  57. package/lib/page-templates/built-in-preview.html +373 -0
  58. package/lib/page-templates/contracts.ts +2 -0
  59. package/lib/page-templates/css.ts +2 -0
  60. package/lib/page-templates/foundation.ts +41 -0
  61. package/lib/page-templates/index.ts +6 -0
  62. package/lib/page-templates/registry.ts +3 -0
  63. package/lib/page-templates/render.ts +1202 -0
  64. package/lib/page-templates/templates/agenda.ts +4 -0
  65. package/lib/page-templates/templates/chart-takeaways.ts +4 -0
  66. package/lib/page-templates/templates/claim-supporting-visual.ts +4 -0
  67. package/lib/page-templates/templates/closing.ts +4 -0
  68. package/lib/page-templates/templates/cover.ts +4 -0
  69. package/lib/page-templates/templates/executive-summary.ts +4 -0
  70. package/lib/page-templates/templates/index.ts +19 -0
  71. package/lib/page-templates/templates/key-message-evidence.ts +4 -0
  72. package/lib/page-templates/templates/metric-highlight.ts +4 -0
  73. package/lib/page-templates/templates/problem-context.ts +4 -0
  74. package/lib/page-templates/templates/process-steps.ts +4 -0
  75. package/lib/page-templates/templates/recommendation-decision.ts +4 -0
  76. package/lib/page-templates/templates/risks-tradeoffs.ts +4 -0
  77. package/lib/page-templates/templates/section-divider.ts +4 -0
  78. package/lib/page-templates/templates/shared.ts +11 -0
  79. package/lib/page-templates/templates/table-comparison.ts +4 -0
  80. package/lib/page-templates/templates/timeline-roadmap.ts +4 -0
  81. package/lib/page-templates/vocabulary.ts +158 -0
  82. package/lib/prompt-builder.ts +5 -5
  83. package/lib/qa/artifact.ts +66 -1
  84. package/lib/qa/compliance.ts +5 -1
  85. package/lib/runtime/index.ts +99 -3
  86. package/package.json +7 -15
  87. package/plugins/revela/.codex-plugin/plugin.json +1 -1
  88. package/plugins/revela/hooks/revela_guard.ts +35 -0
  89. package/plugins/revela/hooks/revela_post_write_notice.ts +4 -4
  90. package/plugins/revela/mcp/revela-server.ts +104 -6
  91. package/plugins/revela/skills/revela/SKILL.md +1 -1
  92. package/plugins/revela/skills/revela-design/SKILL.md +22 -16
  93. package/plugins/revela/skills/revela-helper/SKILL.md +1 -1
  94. package/plugins/revela/skills/revela-make-deck/SKILL.md +25 -16
  95. package/designs/lucent/preview.html +0 -529
  96. package/designs/monet/preview.html +0 -190
  97. package/designs/starter/preview.html +0 -335
  98. package/designs/summit/preview.html +0 -186
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * User designs are stored in ~/.config/revela/designs/<name>/.
5
5
  * Built-in designs are shipped read-only with this package under designs/<name>/.
6
- * Each design directory contains DESIGN.md (required) and optionally preview.html.
6
+ * Each design directory contains DESIGN.md (required), design.css for CSS-native styling,
7
+ * and optionally legacy preview.html. Current previews are generated from the built-in
8
+ * page-template fixture plus the design CSS.
7
9
  */
8
10
 
9
11
  import {
@@ -17,8 +19,9 @@ import {
17
19
  statSync,
18
20
  writeFileSync,
19
21
  } from "fs"
20
- import { dirname, join, resolve, basename, relative, sep } from "path"
22
+ import { dirname, join, normalize, resolve, basename, relative, sep } from "path"
21
23
  import { tmpdir } from "os"
24
+ import { pathToFileURL } from "url"
22
25
  import { parseFrontmatter } from "../frontmatter"
23
26
  import { collectDirectoryEntries, extractEntriesToDirectory, normalizePackageArchiveEntries, readTarArchive, writeTarArchive } from "./archive"
24
27
  import {
@@ -28,11 +31,14 @@ import {
28
31
  saveConfig,
29
32
  } from "../config"
30
33
  import { childLog } from "../log"
34
+ import { PAGE_TEMPLATE_CLASSES, listPageTemplateVocabulary, templateDeckCss } from "../page-templates"
31
35
 
32
36
  const designLog = childLog("designs")
33
37
 
34
38
  // Seed directory: built-in designs shipped with this package.
35
39
  const SEED_DIR = resolve(__dirname, "../..", "designs")
40
+ const BUILT_IN_PREVIEW_PATH = resolve(__dirname, "..", "page-templates", "built-in-preview.html")
41
+ const PREVIEW_FALLBACK_ASSET_DESIGN = "lucent"
36
42
 
37
43
  export interface DesignInfo {
38
44
  name: string
@@ -51,7 +57,8 @@ export interface CreateDesignPackageArgs {
51
57
  name: string
52
58
  base?: string
53
59
  designMd: string
54
- previewHtml: string
60
+ designCss?: string
61
+ previewHtml?: string
55
62
  assets?: DesignPackageAssetInput[]
56
63
  overwrite?: boolean
57
64
  }
@@ -85,6 +92,7 @@ export interface ValidateDesignPackageResult {
85
92
  name: string
86
93
  path: string
87
94
  hasDesignMd: boolean
95
+ hasDesignCss: boolean
88
96
  hasPreview: boolean
89
97
  hasMarkers: boolean
90
98
  sections: string[]
@@ -144,6 +152,43 @@ export interface DesignPreviewInfo {
144
152
  hasPreview: boolean
145
153
  }
146
154
 
155
+ export interface MaterializeDesignPreviewArgs {
156
+ workspaceRoot: string
157
+ name: string
158
+ source?: "draft" | "installed" | "builtin"
159
+ }
160
+
161
+ export interface MaterializeDesignPreviewResult {
162
+ ok: true
163
+ name: string
164
+ source: "draft" | "installed" | "builtin"
165
+ designDir: string
166
+ previewDir: string
167
+ previewPath: string
168
+ previewUrl: string
169
+ browserHandoff: {
170
+ serveRoot: string
171
+ path: string
172
+ urlTemplate: string
173
+ instructions: string
174
+ }
175
+ designCssPath: string
176
+ files: string[]
177
+ warnings: string[]
178
+ }
179
+
180
+ export interface DesignCssSnapshotResult {
181
+ ok: true
182
+ design: string
183
+ sourcePath: string
184
+ snapshotDir: string
185
+ cssPath: string
186
+ href: string
187
+ assetCount: number
188
+ generatedFallback: boolean
189
+ warnings: string[]
190
+ }
191
+
147
192
  // ---------------------------------------------------------------------------
148
193
  // Seed
149
194
  // ---------------------------------------------------------------------------
@@ -300,6 +345,121 @@ export function resolveDesignPreview(name?: string): DesignPreviewInfo {
300
345
  }
301
346
  }
302
347
 
348
+ export function materializeDesignPreview(args: MaterializeDesignPreviewArgs): MaterializeDesignPreviewResult {
349
+ const name = normalizeDesignName(args.name)
350
+ const { designDir, source } = resolveDesignPreviewSourceDir(args.workspaceRoot, name, args.source)
351
+ const previewDir = resolve(args.workspaceRoot, ".revela", "previews", "designs", name)
352
+ const previewPath = join(previewDir, "preview.html")
353
+ const designCssPath = join(previewDir, "design.css")
354
+ const warnings: string[] = []
355
+
356
+ if (!existsSync(BUILT_IN_PREVIEW_PATH)) {
357
+ throw new Error(`Built-in design preview fixture is missing: ${BUILT_IN_PREVIEW_PATH}`)
358
+ }
359
+
360
+ rmSync(previewDir, { recursive: true, force: true })
361
+ mkdirSync(previewDir, { recursive: true })
362
+
363
+ const previewHtml = readFileSync(BUILT_IN_PREVIEW_PATH, "utf-8").replace(/data-design="built-in-preview"/g, `data-design="${name}"`)
364
+ writeFileSync(previewPath, previewHtml, "utf-8")
365
+
366
+ const cssRead = readDesignCssFromDir(name, designDir)
367
+ warnings.push(...cssRead.warnings)
368
+ writeFileSync(designCssPath, `${cssRead.css.trim()}\n`, "utf-8")
369
+
370
+ const sourceAssets = join(designDir, "assets")
371
+ const targetAssets = join(previewDir, "assets")
372
+ if (existsSync(sourceAssets)) cpSync(sourceAssets, targetAssets, { recursive: true })
373
+ copyMissingBuiltInPreviewAssets(previewHtml, targetAssets)
374
+
375
+ return {
376
+ ok: true,
377
+ name,
378
+ source,
379
+ designDir,
380
+ previewDir,
381
+ previewPath,
382
+ previewUrl: pathToFileURL(previewPath).href,
383
+ browserHandoff: {
384
+ serveRoot: previewDir,
385
+ path: "preview.html",
386
+ urlTemplate: "http://127.0.0.1:<port>/preview.html",
387
+ instructions: "Start a read-only local static server from serveRoot, then reply with the localhost URL so the user can click it open in Codex Browser. Do not open the file:// preview directly.",
388
+ },
389
+ designCssPath,
390
+ files: listDesignPackageFiles(previewDir),
391
+ warnings,
392
+ }
393
+ }
394
+
395
+ export function resolveDesignPackageDir(name?: string): string {
396
+ const designName = normalizeDesignName(name || activeDesign())
397
+ const designDir = resolveDesignDir(designName)
398
+ if (!designDir) throw new Error(`Design '${designName}' is not installed`)
399
+ return designDir
400
+ }
401
+
402
+ export function readDesignCss(name?: string): { css: string; path?: string; generatedFallback: boolean; warnings: string[] } {
403
+ const designName = normalizeDesignName(name || activeDesign())
404
+ const designDir = resolveDesignPackageDir(designName)
405
+ const cssPath = join(designDir, "design.css")
406
+ if (existsSync(cssPath)) {
407
+ return { css: readFileSync(cssPath, "utf-8"), path: cssPath, generatedFallback: false, warnings: [] }
408
+ }
409
+
410
+ const foundation = getDesignSection("foundation", designName)
411
+ const cssBlocks = extractCssCodeBlocks(foundation)
412
+ if (cssBlocks.length === 0) throw new Error(`Design '${designName}' has no design.css and no CSS fallback in @design:foundation.`)
413
+ return {
414
+ css: [
415
+ "/* Generated compatibility CSS from DESIGN.md. Add design.css to make this design fully CSS-package native. */",
416
+ ...cssBlocks,
417
+ templateDeckCss({ designName, designAssetBasePath: "./assets" }),
418
+ ].join("\n\n"),
419
+ generatedFallback: true,
420
+ warnings: [`Design '${designName}' has no design.css; generated a deck-local compatibility CSS snapshot from DESIGN.md.`],
421
+ }
422
+ }
423
+
424
+ export function materializeDesignCssSnapshot(input: {
425
+ workspaceRoot: string
426
+ outputPath: string
427
+ designName?: string
428
+ }): DesignCssSnapshotResult {
429
+ const designName = normalizeDesignName(input.designName || activeDesign())
430
+ const designDir = resolveDesignPackageDir(designName)
431
+ const outputDir = dirname(normalize(input.outputPath))
432
+ const snapshotRelDir = normalize(join(outputDir, "_revela-design", designName)).replace(/\\/g, "/")
433
+ const snapshotDir = resolve(input.workspaceRoot, snapshotRelDir)
434
+ const cssPath = join(snapshotDir, "design.css")
435
+ const cssRead = readDesignCss(designName)
436
+
437
+ mkdirSync(snapshotDir, { recursive: true })
438
+ writeFileSync(cssPath, cssRead.css.endsWith("\n") ? cssRead.css : `${cssRead.css}\n`, "utf-8")
439
+
440
+ const assetsDir = join(designDir, "assets")
441
+ let assetCount = 0
442
+ if (existsSync(assetsDir) && statSync(assetsDir).isDirectory()) {
443
+ const targetAssets = join(snapshotDir, "assets")
444
+ rmSync(targetAssets, { recursive: true, force: true })
445
+ cpSync(assetsDir, targetAssets, { recursive: true })
446
+ assetCount = listDesignPackageFiles(targetAssets).length
447
+ }
448
+
449
+ const href = normalize(relative(outputDir || ".", normalize(join(snapshotRelDir, "design.css")))).replace(/\\/g, "/")
450
+ return {
451
+ ok: true,
452
+ design: designName,
453
+ sourcePath: cssRead.path || join(designDir, "DESIGN.md"),
454
+ snapshotDir,
455
+ cssPath,
456
+ href: href.startsWith(".") ? href : `./${href}`,
457
+ assetCount,
458
+ generatedFallback: cssRead.generatedFallback,
459
+ warnings: cssRead.warnings,
460
+ }
461
+ }
462
+
303
463
  /** Normalize and validate a design package name. */
304
464
  export function normalizeDesignName(name: string): string {
305
465
  const normalized = name.trim().toLowerCase()
@@ -313,10 +473,10 @@ export function normalizeDesignName(name: string): string {
313
473
  export function createDesignPackage(args: CreateDesignPackageArgs): CreateDesignPackageResult {
314
474
  const name = normalizeDesignName(args.name)
315
475
  const designMd = args.designMd?.trim()
476
+ const designCss = args.designCss?.trim()
316
477
  const previewHtml = args.previewHtml?.trim()
317
478
 
318
479
  if (!designMd) throw new Error("designMd is required")
319
- if (!previewHtml) throw new Error("previewHtml is required")
320
480
 
321
481
  const target = join(DESIGNS_DIR, name)
322
482
  const existed = existsSync(target)
@@ -330,7 +490,8 @@ export function createDesignPackage(args: CreateDesignPackageArgs): CreateDesign
330
490
  }
331
491
  mkdirSync(target, { recursive: true })
332
492
  writeFileSync(join(target, "DESIGN.md"), `${designMd}\n`, "utf-8")
333
- writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8")
493
+ if (designCss) writeFileSync(join(target, "design.css"), `${designCss}\n`, "utf-8")
494
+ if (previewHtml) writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8")
334
495
  writeDesignAssets(target, args.assets)
335
496
 
336
497
  const validation = validateDesignPackage(name)
@@ -353,10 +514,10 @@ export function createDesignPackage(args: CreateDesignPackageArgs): CreateDesign
353
514
  export function createDesignDraftPackage(args: CreateDesignDraftArgs): CreateDesignPackageResult {
354
515
  const name = normalizeDesignName(args.name)
355
516
  const designMd = args.designMd?.trim()
517
+ const designCss = args.designCss?.trim()
356
518
  const previewHtml = args.previewHtml?.trim()
357
519
 
358
520
  if (!designMd) throw new Error("designMd is required")
359
- if (!previewHtml) throw new Error("previewHtml is required")
360
521
 
361
522
  const target = designDraftDir(args.workspaceRoot, name)
362
523
  const existed = existsSync(target)
@@ -370,7 +531,8 @@ export function createDesignDraftPackage(args: CreateDesignDraftArgs): CreateDes
370
531
  }
371
532
  mkdirSync(target, { recursive: true })
372
533
  writeFileSync(join(target, "DESIGN.md"), `${designMd}\n`, "utf-8")
373
- writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8")
534
+ if (designCss) writeFileSync(join(target, "design.css"), `${designCss}\n`, "utf-8")
535
+ if (previewHtml) writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8")
374
536
  writeDesignAssets(target, args.assets)
375
537
 
376
538
  const validation = validateDesignDraftPackage(args.workspaceRoot, name)
@@ -560,8 +722,10 @@ function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesign
560
722
  }
561
723
 
562
724
  const mdPath = join(dir, "DESIGN.md")
725
+ const cssPath = join(dir, "design.css")
563
726
  const previewPath = join(dir, "preview.html")
564
727
  const hasDesignMd = existsSync(mdPath)
728
+ const hasDesignCss = existsSync(cssPath)
565
729
  const hasPreview = existsSync(previewPath)
566
730
  let hasMarkers = false
567
731
  let sections: string[] = []
@@ -571,7 +735,7 @@ function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesign
571
735
 
572
736
  if (!existsSync(dir)) errors.push(`Design directory does not exist: ${dir}`)
573
737
  if (!hasDesignMd) errors.push("DESIGN.md is missing")
574
- if (!hasPreview) errors.push("preview.html is missing")
738
+ if (!hasDesignCss) warnings.push("design.css is missing; DESIGN.md CSS fallback remains compatibility-only and should be migrated.")
575
739
  if (existsSync(dir)) assets = listDesignAssetsInDir(dir)
576
740
 
577
741
  if (hasDesignMd) {
@@ -595,11 +759,13 @@ function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesign
595
759
 
596
760
  if (hasPreview) {
597
761
  const preview = readFileSync(previewPath, "utf-8")
762
+ const designCss = hasDesignCss ? readFileSync(cssPath, "utf-8") : ""
598
763
  if (!preview.includes('<section class="slide"')) errors.push("preview.html must include slide sections")
599
764
  if (!preview.includes("slide-qa=")) errors.push("preview.html slides must include slide-qa attributes")
600
765
  if (!preview.includes("slide-canvas")) errors.push("preview.html must include .slide-canvas")
601
- if (!hasFixedSizeCssRule(preview, "slide-canvas")) {
602
- errors.push("preview.html must define .slide-canvas CSS with width: 1920px and height: 1080px")
766
+ if (hasDesignCss && !previewReferencesDesignCss(preview)) errors.push("preview.html must reference design.css")
767
+ if (!hasFixedSizeCssRule(`${preview}\n${designCss}`, "slide-canvas")) {
768
+ errors.push("preview.html or design.css must define .slide-canvas CSS with width: 1920px and height: 1080px")
603
769
  }
604
770
  if (!hasSlideRole(preview, "cover")) errors.push('preview.html must include a slide section with data-slide-role="cover"')
605
771
  if (!hasSlideRole(preview, "closing")) errors.push('preview.html must include a slide section with data-slide-role="closing"')
@@ -611,16 +777,24 @@ function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesign
611
777
  if (missingLayoutPreviews.length > 0) {
612
778
  warnings.push(`preview.html should mark layout fixtures with data-preview-layout; missing: ${missingLayoutPreviews.join(", ")}`)
613
779
  }
614
- const designText = hasDesignMd ? readFileSync(mdPath, "utf-8") : ""
615
- const tokenWarnings = designContractTokenWarnings(`${designText}\n${preview}`)
616
- warnings.push(...tokenWarnings)
617
780
  }
781
+ if (hasDesignCss) {
782
+ const designCss = readFileSync(cssPath, "utf-8")
783
+ const missingCoreClasses = requiredDesignCssClasses().filter((className) => !cssTextHasClassSelector(designCss, className))
784
+ if (missingCoreClasses.length > 0) errors.push(`design.css must style core template classes; missing: ${missingCoreClasses.map((item) => `.${item}`).join(", ")}`)
785
+ errors.push(...missingDesignCssAssetErrors(designCss, dir))
786
+ }
787
+ const designText = hasDesignMd ? readFileSync(mdPath, "utf-8") : ""
788
+ const designCss = hasDesignCss ? readFileSync(cssPath, "utf-8") : ""
789
+ const tokenWarnings = designContractTokenWarnings(`${designText}\n${designCss}`)
790
+ warnings.push(...tokenWarnings)
618
791
 
619
792
  return {
620
793
  ok: errors.length === 0,
621
794
  name,
622
795
  path: dir,
623
796
  hasDesignMd,
797
+ hasDesignCss,
624
798
  hasPreview,
625
799
  hasMarkers,
626
800
  sections,
@@ -641,15 +815,128 @@ function designContractTokenWarnings(text: string): string[] {
641
815
  { label: "surface", pattern: /--surface|surface token|border token|shadow token/i },
642
816
  ]
643
817
  for (const check of checks) {
644
- if (!check.pattern.test(text)) warnings.push(`DESIGN.md/preview.html should document ${check.label} design tokens or an equivalent contract`)
818
+ if (!check.pattern.test(text)) warnings.push(`DESIGN.md/design.css should document ${check.label} design tokens or an equivalent contract`)
645
819
  }
646
820
  return warnings
647
821
  }
648
822
 
823
+ function previewReferencesDesignCss(preview: string): boolean {
824
+ return /<link\b[^>]*href=["'][^"']*design\.css["'][^>]*>/i.test(preview)
825
+ }
826
+
827
+ function requiredDesignCssClasses(): string[] {
828
+ return [
829
+ "slide-canvas",
830
+ "template-slide",
831
+ "template-frame",
832
+ "template-title",
833
+ "template-card",
834
+ "template-visual-slot-panel",
835
+ ]
836
+ }
837
+
838
+ function cssTextHasClassSelector(css: string, className: string): boolean {
839
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "")
840
+ const ruleRe = /([^{}]+)\{[^{}]*\}/g
841
+ let match: RegExpExecArray | null
842
+ while ((match = ruleRe.exec(withoutComments)) !== null) {
843
+ if (cssRuleHasClassSelector(match[1] ?? "", className)) return true
844
+ }
845
+ return false
846
+ }
847
+
848
+ function missingDesignCssAssetErrors(css: string, designDir: string): string[] {
849
+ const errors: string[] = []
850
+ const seen = new Set<string>()
851
+ const urlRe = /url\(\s*["']?([^"')]+)["']?\s*\)/gi
852
+ let match: RegExpExecArray | null
853
+ while ((match = urlRe.exec(css)) !== null) {
854
+ const raw = match[1].trim()
855
+ if (!raw || raw.startsWith("data:") || /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("#")) continue
856
+ const normalized = raw.replace(/^\.\/+/, "")
857
+ if (!normalized.startsWith("assets/")) continue
858
+ if (seen.has(normalized)) continue
859
+ seen.add(normalized)
860
+ if (!existsSync(join(designDir, ...normalized.split("/")))) errors.push(`design.css references missing asset: ${normalized}`)
861
+ }
862
+ return errors
863
+ }
864
+
865
+ function extractCssCodeBlocks(markdown: string): string[] {
866
+ const blocks: string[] = []
867
+ const fenceRe = /```css\n([\s\S]*?)```/g
868
+ let match: RegExpExecArray | null
869
+ while ((match = fenceRe.exec(markdown)) !== null) {
870
+ const body = match[1].trim()
871
+ if (body) blocks.push(body)
872
+ }
873
+ return blocks
874
+ }
875
+
649
876
  function designDraftDir(workspaceRoot: string, name: string): string {
650
877
  return resolve(workspaceRoot, ".revela", "drafts", "designs", name)
651
878
  }
652
879
 
880
+ function resolveDesignPreviewSourceDir(
881
+ workspaceRoot: string,
882
+ name: string,
883
+ requested?: "draft" | "installed" | "builtin",
884
+ ): { designDir: string; source: "draft" | "installed" | "builtin" } {
885
+ const draftDir = designDraftDir(workspaceRoot, name)
886
+ if (requested === "draft") {
887
+ if (!existsSync(draftDir)) throw new Error(`Design draft '${name}' does not exist: ${draftDir}`)
888
+ return { designDir: draftDir, source: "draft" }
889
+ }
890
+
891
+ const installedDir = resolveDesignDir(name)
892
+ if (requested === "installed" || requested === "builtin") {
893
+ if (!installedDir) throw new Error(`Design '${name}' is not installed`)
894
+ const source = requested === "builtin" ? "builtin" : installedDir.startsWith(SEED_DIR + sep) ? "builtin" : "installed"
895
+ return { designDir: installedDir, source }
896
+ }
897
+
898
+ if (existsSync(draftDir)) return { designDir: draftDir, source: "draft" }
899
+ if (!installedDir) throw new Error(`Design '${name}' is not available as a workspace draft or installed design`)
900
+ return { designDir: installedDir, source: installedDir.startsWith(SEED_DIR + sep) ? "builtin" : "installed" }
901
+ }
902
+
903
+ function readDesignCssFromDir(name: string, designDir: string): { css: string; warnings: string[] } {
904
+ const cssPath = join(designDir, "design.css")
905
+ if (existsSync(cssPath)) return { css: readFileSync(cssPath, "utf-8"), warnings: [] }
906
+
907
+ const mdPath = join(designDir, "DESIGN.md")
908
+ const markdown = existsSync(mdPath) ? readFileSync(mdPath, "utf-8") : ""
909
+ const cssBlocks = extractCssCodeBlocks(markdown)
910
+ const css = [
911
+ "/* Generated preview compatibility CSS from DESIGN.md. Add design.css to make this design CSS-native. */",
912
+ templateDeckCss(),
913
+ ...cssBlocks,
914
+ ].join("\n\n")
915
+ return {
916
+ css,
917
+ warnings: [`Design '${name}' has no design.css; generated preview CSS from DESIGN.md compatibility blocks.`],
918
+ }
919
+ }
920
+
921
+ function copyMissingBuiltInPreviewAssets(previewHtml: string, targetAssetsDir: string): void {
922
+ const fallbackAssetsDir = join(SEED_DIR, PREVIEW_FALLBACK_ASSET_DESIGN, "assets")
923
+ const assetRefs = new Set<string>()
924
+ const re = /(?:src|href)=["'](?:\.\/)?assets\/([^"']+)["']/g
925
+ let match: RegExpExecArray | null
926
+ while ((match = re.exec(previewHtml)) !== null) assetRefs.add(match[1])
927
+ if (assetRefs.size === 0) return
928
+
929
+ mkdirSync(targetAssetsDir, { recursive: true })
930
+ for (const rel of assetRefs) {
931
+ const target = resolve(targetAssetsDir, rel)
932
+ if (existsSync(target)) continue
933
+ const source = resolve(fallbackAssetsDir, rel)
934
+ if (!existsSync(source) || !statSync(source).isFile()) continue
935
+ mkdirSync(dirname(target), { recursive: true })
936
+ cpSync(source, target)
937
+ }
938
+ }
939
+
653
940
  function designDraftExists(workspaceRoot: string, name: string): boolean {
654
941
  const dir = designDraftDir(workspaceRoot, name)
655
942
  return existsSync(dir) && statSync(dir).isDirectory() && existsSync(join(dir, "DESIGN.md"))
@@ -812,6 +1099,12 @@ export interface DesignInventory {
812
1099
  sections: string[]
813
1100
  layouts: DesignInventoryLayout[]
814
1101
  components: DesignInventoryComponent[]
1102
+ pageTemplates: Array<{
1103
+ templateId: string
1104
+ slots: string[]
1105
+ requiredClasses: string[]
1106
+ contractNotes: string[]
1107
+ }>
815
1108
  assets: DesignPackageAssetInfo[]
816
1109
  hasMarkers: boolean
817
1110
  }
@@ -953,6 +1246,12 @@ export function getDesignInventory(designName?: string): DesignInventory {
953
1246
  nesting: inferComponentNesting(componentName),
954
1247
  contract: inferComponentContract(componentName, content),
955
1248
  })),
1249
+ pageTemplates: listPageTemplateVocabulary().map((template) => ({
1250
+ templateId: template.templateId,
1251
+ slots: template.slots.map((slot) => slot.name),
1252
+ requiredClasses: template.requiredClasses,
1253
+ contractNotes: template.contractNotes,
1254
+ })),
956
1255
  assets: listDesignAssetsInDir(designDir),
957
1256
  hasMarkers,
958
1257
  }
@@ -1225,6 +1524,7 @@ const UNIVERSAL_CLASSES = new Set([
1225
1524
  "overlay",
1226
1525
  "alt",
1227
1526
  "strong",
1527
+ ...PAGE_TEMPLATE_CLASSES,
1228
1528
  ])
1229
1529
 
1230
1530
  /**
@@ -1360,6 +1660,9 @@ export function extractDesignClasses(designName?: string): DesignClassVocabulary
1360
1660
  extractFromSection(content)
1361
1661
  }
1362
1662
 
1663
+ const cssPath = join(designDir, "design.css")
1664
+ if (existsSync(cssPath)) extractFromCss(readFileSync(cssPath, "utf-8"))
1665
+
1363
1666
  return { classes, prefixExemptions: DEFAULT_PREFIX_EXEMPTIONS }
1364
1667
  }
1365
1668
 
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, wri
2
2
  import { dirname, join, relative } from "path"
3
3
  import { createHash } from "crypto"
4
4
  import type { DeckSpec, SlideSpec } from "../decks-state"
5
+ import { listPageTemplates } from "../page-templates"
5
6
  import { parseVaultFrontmatter } from "../narrative-vault/frontmatter"
6
7
  import { splitMarkdownSections } from "../narrative-vault/markdown"
7
8
  import { stableVaultRelationId } from "../narrative-vault/relations"
@@ -92,6 +93,8 @@ export interface DeckPlanSlideProjection {
92
93
  slideIndex?: number
93
94
  title: string
94
95
  chapter: string
96
+ template?: string
97
+ templateContent?: Record<string, any>
95
98
  layout: string
96
99
  components: string[]
97
100
  componentPlan: DeckPlanSlideComponentPlan[]
@@ -575,6 +578,8 @@ function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string
575
578
  slideIndex: numberField(parsed.frontmatter, "slideIndex"),
576
579
  title: stringField(parsed.frontmatter, "title") || id,
577
580
  chapter: stringField(parsed.frontmatter, "chapter"),
581
+ template: stringField(parsed.frontmatter, "template"),
582
+ templateContent: parseTemplateContent(split.sections["template-content"] ?? ""),
578
583
  layout: stringField(parsed.frontmatter, "layout"),
579
584
  components: arrayField(parsed.frontmatter, "components"),
580
585
  componentPlan,
@@ -623,6 +628,8 @@ function readDeckPlanSlidesFromSingleFile(workspaceRoot: string, absolutePath: s
623
628
  slideIndex,
624
629
  title,
625
630
  chapter: fields.chapter || "",
631
+ template: fields.template || "",
632
+ templateContent: parseTemplateContent(singleFileSubsection(block, "Template Content")),
626
633
  layout: fields.layout || "",
627
634
  components: parseCsv(fields.components || componentPlan.map((component) => component.name).join(", ")),
628
635
  componentPlan,
@@ -671,6 +678,8 @@ function readDeckPlanSeparatorSlidesFromSingleFile(workspaceRoot: string, absolu
671
678
  slideIndex,
672
679
  title,
673
680
  chapter: stringField(parsed.frontmatter, "chapter") || fields.chapter || "",
681
+ template: stringField(parsed.frontmatter, "template") || fields.template || "",
682
+ templateContent: parseTemplateContent(singleFileSubsection(block, "Template Content")),
674
683
  layout: stringField(parsed.frontmatter, "layout") || fields.layout || "",
675
684
  components: arrayField(parsed.frontmatter, "components").length > 0 ? arrayField(parsed.frontmatter, "components") : parseCsv(fields.components || componentPlan.map((component) => component.name).join(", ")),
676
685
  componentPlan,
@@ -702,6 +711,27 @@ function parseSlideBlockFields(block: string): Record<string, string> {
702
711
  return fields
703
712
  }
704
713
 
714
+ function parseTemplateContent(section: string): Record<string, any> | undefined {
715
+ const trimmed = section.trim()
716
+ if (!trimmed) return undefined
717
+ const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```$/i.exec(trimmed)
718
+ const body = fenced ? fenced[1].trim() : trimmed
719
+ if (body.startsWith("{")) {
720
+ try {
721
+ return JSON.parse(body)
722
+ } catch {
723
+ return { raw: body }
724
+ }
725
+ }
726
+ const result: Record<string, any> = {}
727
+ for (const rawLine of body.split(/\r?\n/)) {
728
+ const match = /^-\s+([A-Za-z][A-Za-z0-9 _-]*):\s*(.*)$/.exec(rawLine.trim())
729
+ if (!match) continue
730
+ result[match[1].trim().replace(/\s+/g, "_").toLowerCase()] = cleanPlanValue(match[2])
731
+ }
732
+ return Object.keys(result).length > 0 ? result : { raw: body }
733
+ }
734
+
705
735
  function singleFileSubsection(block: string, heading: string): string {
706
736
  const re = new RegExp(`^[ \\t]*####\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "mi")
707
737
  const match = re.exec(block)
@@ -906,9 +936,11 @@ function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<str
906
936
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
907
937
  if (!slide.structural && linksCount(slide.sourceLinks) === 0) diagnostics.push({ severity: "warning", code: "slide_source_link_missing", message: `Non-structural deck-plan slide ${slide.id} has no material, finding, asset, or URL source link.`, file: slide.path, nodeId: slide.id })
908
938
  if (!slide.structural && linksCount(slide.sourceLinks) > 0) diagnostics.push(...slideSynthesisDiagnostics(slide))
909
- if (!slide.layout) diagnostics.push({ severity: "warning", code: "slide_layout_missing", message: `Deck-plan slide ${slide.id} is missing a layout.`, file: slide.path, nodeId: slide.id })
910
- if (slide.components.length === 0) diagnostics.push({ severity: "warning", code: "slide_components_missing", message: `Deck-plan slide ${slide.id} has no component names in frontmatter.`, file: slide.path, nodeId: slide.id })
911
- if (slide.componentPlan.length === 0) diagnostics.push({ severity: "warning", code: "slide_component_plan_missing", message: `Deck-plan slide ${slide.id} is missing structured ## Component Plan entries.`, file: slide.path, nodeId: slide.id })
939
+ if (slide.template && !knownTemplateIds().has(slide.template)) diagnostics.push({ severity: "warning", code: "slide_template_unknown", message: `Deck-plan slide ${slide.id} uses unknown page template '${slide.template}'.`, file: slide.path, nodeId: slide.id })
940
+ if (slide.template && !slide.templateContent) diagnostics.push({ severity: "warning", code: "slide_template_content_missing", message: `Deck-plan slide ${slide.id} uses template '${slide.template}' but has no #### Template Content section.`, file: slide.path, nodeId: slide.id })
941
+ if (!slide.template && !slide.layout) diagnostics.push({ severity: "warning", code: "slide_layout_missing", message: `Deck-plan slide ${slide.id} is missing a layout.`, file: slide.path, nodeId: slide.id })
942
+ if (!slide.template && slide.components.length === 0) diagnostics.push({ severity: "warning", code: "slide_components_missing", message: `Deck-plan slide ${slide.id} has no component names in frontmatter.`, file: slide.path, nodeId: slide.id })
943
+ if (!slide.template && slide.componentPlan.length === 0) diagnostics.push({ severity: "warning", code: "slide_component_plan_missing", message: `Deck-plan slide ${slide.id} is missing structured ## Component Plan entries.`, file: slide.path, nodeId: slide.id })
912
944
  for (const component of slide.componentPlan) {
913
945
  for (const key of ["name", "slot", "position", "purpose", "content"] as const) {
914
946
  if (!component[key] || (Array.isArray(component[key]) && component[key].length === 0)) diagnostics.push({ severity: "warning", code: "slide_component_plan_incomplete", message: `Deck-plan slide ${slide.id} has incomplete component plan entry for ${component.name || "unnamed component"}: missing ${key}.`, file: slide.path, nodeId: slide.id })
@@ -922,6 +954,10 @@ function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<str
922
954
  return diagnostics
923
955
  }
924
956
 
957
+ function knownTemplateIds(): Set<string> {
958
+ return new Set(listPageTemplates().templates.map((template) => template.id))
959
+ }
960
+
925
961
  function slideSynthesisDiagnostics(slide: DeckPlanSlideProjection): DeckPlanProjectionDiagnostic[] {
926
962
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
927
963
  const contentPlan = singleFileSubsection(slide.markdown, "Content Plan")
@@ -958,6 +994,7 @@ export function deckPlanDesignDiagnostics(projection: DeckPlanProjection | undef
958
994
  const components = new Set(inventory.components)
959
995
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
960
996
  for (const slide of projection.slides) {
997
+ if (slide.template) continue
961
998
  if (slide.layout && !layouts.has(slide.layout)) diagnostics.push({ severity: "warning", code: "slide_layout_unknown", message: `Deck-plan slide ${slide.id} uses layout '${slide.layout}' outside the active design inventory.`, file: slide.path, nodeId: slide.id })
962
999
  for (const component of slide.componentPlan) diagnostics.push(...componentDesignDiagnostics(slide, component, inventory, components))
963
1000
  }