@cyber-dash-tech/revela 0.18.16 → 0.19.0

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 (95) hide show
  1. package/README.md +27 -13
  2. package/README.zh-CN.md +27 -13
  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 +305 -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/mcp/revela-server.ts +101 -5
  90. package/plugins/revela/skills/revela-design/SKILL.md +21 -16
  91. package/plugins/revela/skills/revela-make-deck/SKILL.md +24 -15
  92. package/designs/lucent/preview.html +0 -529
  93. package/designs/monet/preview.html +0 -190
  94. package/designs/starter/preview.html +0 -335
  95. 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,37 @@ 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
+ designCssPath: string
170
+ files: string[]
171
+ warnings: string[]
172
+ }
173
+
174
+ export interface DesignCssSnapshotResult {
175
+ ok: true
176
+ design: string
177
+ sourcePath: string
178
+ snapshotDir: string
179
+ cssPath: string
180
+ href: string
181
+ assetCount: number
182
+ generatedFallback: boolean
183
+ warnings: string[]
184
+ }
185
+
147
186
  // ---------------------------------------------------------------------------
148
187
  // Seed
149
188
  // ---------------------------------------------------------------------------
@@ -300,6 +339,115 @@ export function resolveDesignPreview(name?: string): DesignPreviewInfo {
300
339
  }
301
340
  }
302
341
 
342
+ export function materializeDesignPreview(args: MaterializeDesignPreviewArgs): MaterializeDesignPreviewResult {
343
+ const name = normalizeDesignName(args.name)
344
+ const { designDir, source } = resolveDesignPreviewSourceDir(args.workspaceRoot, name, args.source)
345
+ const previewDir = resolve(args.workspaceRoot, ".revela", "previews", "designs", name)
346
+ const previewPath = join(previewDir, "preview.html")
347
+ const designCssPath = join(previewDir, "design.css")
348
+ const warnings: string[] = []
349
+
350
+ if (!existsSync(BUILT_IN_PREVIEW_PATH)) {
351
+ throw new Error(`Built-in design preview fixture is missing: ${BUILT_IN_PREVIEW_PATH}`)
352
+ }
353
+
354
+ rmSync(previewDir, { recursive: true, force: true })
355
+ mkdirSync(previewDir, { recursive: true })
356
+
357
+ const previewHtml = readFileSync(BUILT_IN_PREVIEW_PATH, "utf-8").replace(/data-design="built-in-preview"/g, `data-design="${name}"`)
358
+ writeFileSync(previewPath, previewHtml, "utf-8")
359
+
360
+ const cssRead = readDesignCssFromDir(name, designDir)
361
+ warnings.push(...cssRead.warnings)
362
+ writeFileSync(designCssPath, `${cssRead.css.trim()}\n`, "utf-8")
363
+
364
+ const sourceAssets = join(designDir, "assets")
365
+ const targetAssets = join(previewDir, "assets")
366
+ if (existsSync(sourceAssets)) cpSync(sourceAssets, targetAssets, { recursive: true })
367
+ copyMissingBuiltInPreviewAssets(previewHtml, targetAssets)
368
+
369
+ return {
370
+ ok: true,
371
+ name,
372
+ source,
373
+ designDir,
374
+ previewDir,
375
+ previewPath,
376
+ previewUrl: pathToFileURL(previewPath).href,
377
+ designCssPath,
378
+ files: listDesignPackageFiles(previewDir),
379
+ warnings,
380
+ }
381
+ }
382
+
383
+ export function resolveDesignPackageDir(name?: string): string {
384
+ const designName = normalizeDesignName(name || activeDesign())
385
+ const designDir = resolveDesignDir(designName)
386
+ if (!designDir) throw new Error(`Design '${designName}' is not installed`)
387
+ return designDir
388
+ }
389
+
390
+ export function readDesignCss(name?: string): { css: string; path?: string; generatedFallback: boolean; warnings: string[] } {
391
+ const designName = normalizeDesignName(name || activeDesign())
392
+ const designDir = resolveDesignPackageDir(designName)
393
+ const cssPath = join(designDir, "design.css")
394
+ if (existsSync(cssPath)) {
395
+ return { css: readFileSync(cssPath, "utf-8"), path: cssPath, generatedFallback: false, warnings: [] }
396
+ }
397
+
398
+ const foundation = getDesignSection("foundation", designName)
399
+ const cssBlocks = extractCssCodeBlocks(foundation)
400
+ if (cssBlocks.length === 0) throw new Error(`Design '${designName}' has no design.css and no CSS fallback in @design:foundation.`)
401
+ return {
402
+ css: [
403
+ "/* Generated compatibility CSS from DESIGN.md. Add design.css to make this design fully CSS-package native. */",
404
+ ...cssBlocks,
405
+ templateDeckCss({ designName, designAssetBasePath: "./assets" }),
406
+ ].join("\n\n"),
407
+ generatedFallback: true,
408
+ warnings: [`Design '${designName}' has no design.css; generated a deck-local compatibility CSS snapshot from DESIGN.md.`],
409
+ }
410
+ }
411
+
412
+ export function materializeDesignCssSnapshot(input: {
413
+ workspaceRoot: string
414
+ outputPath: string
415
+ designName?: string
416
+ }): DesignCssSnapshotResult {
417
+ const designName = normalizeDesignName(input.designName || activeDesign())
418
+ const designDir = resolveDesignPackageDir(designName)
419
+ const outputDir = dirname(normalize(input.outputPath))
420
+ const snapshotRelDir = normalize(join(outputDir, "_revela-design", designName)).replace(/\\/g, "/")
421
+ const snapshotDir = resolve(input.workspaceRoot, snapshotRelDir)
422
+ const cssPath = join(snapshotDir, "design.css")
423
+ const cssRead = readDesignCss(designName)
424
+
425
+ mkdirSync(snapshotDir, { recursive: true })
426
+ writeFileSync(cssPath, cssRead.css.endsWith("\n") ? cssRead.css : `${cssRead.css}\n`, "utf-8")
427
+
428
+ const assetsDir = join(designDir, "assets")
429
+ let assetCount = 0
430
+ if (existsSync(assetsDir) && statSync(assetsDir).isDirectory()) {
431
+ const targetAssets = join(snapshotDir, "assets")
432
+ rmSync(targetAssets, { recursive: true, force: true })
433
+ cpSync(assetsDir, targetAssets, { recursive: true })
434
+ assetCount = listDesignPackageFiles(targetAssets).length
435
+ }
436
+
437
+ const href = normalize(relative(outputDir || ".", normalize(join(snapshotRelDir, "design.css")))).replace(/\\/g, "/")
438
+ return {
439
+ ok: true,
440
+ design: designName,
441
+ sourcePath: cssRead.path || join(designDir, "DESIGN.md"),
442
+ snapshotDir,
443
+ cssPath,
444
+ href: href.startsWith(".") ? href : `./${href}`,
445
+ assetCount,
446
+ generatedFallback: cssRead.generatedFallback,
447
+ warnings: cssRead.warnings,
448
+ }
449
+ }
450
+
303
451
  /** Normalize and validate a design package name. */
304
452
  export function normalizeDesignName(name: string): string {
305
453
  const normalized = name.trim().toLowerCase()
@@ -313,10 +461,10 @@ export function normalizeDesignName(name: string): string {
313
461
  export function createDesignPackage(args: CreateDesignPackageArgs): CreateDesignPackageResult {
314
462
  const name = normalizeDesignName(args.name)
315
463
  const designMd = args.designMd?.trim()
464
+ const designCss = args.designCss?.trim()
316
465
  const previewHtml = args.previewHtml?.trim()
317
466
 
318
467
  if (!designMd) throw new Error("designMd is required")
319
- if (!previewHtml) throw new Error("previewHtml is required")
320
468
 
321
469
  const target = join(DESIGNS_DIR, name)
322
470
  const existed = existsSync(target)
@@ -330,7 +478,8 @@ export function createDesignPackage(args: CreateDesignPackageArgs): CreateDesign
330
478
  }
331
479
  mkdirSync(target, { recursive: true })
332
480
  writeFileSync(join(target, "DESIGN.md"), `${designMd}\n`, "utf-8")
333
- writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8")
481
+ if (designCss) writeFileSync(join(target, "design.css"), `${designCss}\n`, "utf-8")
482
+ if (previewHtml) writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8")
334
483
  writeDesignAssets(target, args.assets)
335
484
 
336
485
  const validation = validateDesignPackage(name)
@@ -353,10 +502,10 @@ export function createDesignPackage(args: CreateDesignPackageArgs): CreateDesign
353
502
  export function createDesignDraftPackage(args: CreateDesignDraftArgs): CreateDesignPackageResult {
354
503
  const name = normalizeDesignName(args.name)
355
504
  const designMd = args.designMd?.trim()
505
+ const designCss = args.designCss?.trim()
356
506
  const previewHtml = args.previewHtml?.trim()
357
507
 
358
508
  if (!designMd) throw new Error("designMd is required")
359
- if (!previewHtml) throw new Error("previewHtml is required")
360
509
 
361
510
  const target = designDraftDir(args.workspaceRoot, name)
362
511
  const existed = existsSync(target)
@@ -370,7 +519,8 @@ export function createDesignDraftPackage(args: CreateDesignDraftArgs): CreateDes
370
519
  }
371
520
  mkdirSync(target, { recursive: true })
372
521
  writeFileSync(join(target, "DESIGN.md"), `${designMd}\n`, "utf-8")
373
- writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8")
522
+ if (designCss) writeFileSync(join(target, "design.css"), `${designCss}\n`, "utf-8")
523
+ if (previewHtml) writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8")
374
524
  writeDesignAssets(target, args.assets)
375
525
 
376
526
  const validation = validateDesignDraftPackage(args.workspaceRoot, name)
@@ -560,8 +710,10 @@ function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesign
560
710
  }
561
711
 
562
712
  const mdPath = join(dir, "DESIGN.md")
713
+ const cssPath = join(dir, "design.css")
563
714
  const previewPath = join(dir, "preview.html")
564
715
  const hasDesignMd = existsSync(mdPath)
716
+ const hasDesignCss = existsSync(cssPath)
565
717
  const hasPreview = existsSync(previewPath)
566
718
  let hasMarkers = false
567
719
  let sections: string[] = []
@@ -571,7 +723,7 @@ function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesign
571
723
 
572
724
  if (!existsSync(dir)) errors.push(`Design directory does not exist: ${dir}`)
573
725
  if (!hasDesignMd) errors.push("DESIGN.md is missing")
574
- if (!hasPreview) errors.push("preview.html is missing")
726
+ if (!hasDesignCss) warnings.push("design.css is missing; DESIGN.md CSS fallback remains compatibility-only and should be migrated.")
575
727
  if (existsSync(dir)) assets = listDesignAssetsInDir(dir)
576
728
 
577
729
  if (hasDesignMd) {
@@ -595,11 +747,13 @@ function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesign
595
747
 
596
748
  if (hasPreview) {
597
749
  const preview = readFileSync(previewPath, "utf-8")
750
+ const designCss = hasDesignCss ? readFileSync(cssPath, "utf-8") : ""
598
751
  if (!preview.includes('<section class="slide"')) errors.push("preview.html must include slide sections")
599
752
  if (!preview.includes("slide-qa=")) errors.push("preview.html slides must include slide-qa attributes")
600
753
  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")
754
+ if (hasDesignCss && !previewReferencesDesignCss(preview)) errors.push("preview.html must reference design.css")
755
+ if (!hasFixedSizeCssRule(`${preview}\n${designCss}`, "slide-canvas")) {
756
+ errors.push("preview.html or design.css must define .slide-canvas CSS with width: 1920px and height: 1080px")
603
757
  }
604
758
  if (!hasSlideRole(preview, "cover")) errors.push('preview.html must include a slide section with data-slide-role="cover"')
605
759
  if (!hasSlideRole(preview, "closing")) errors.push('preview.html must include a slide section with data-slide-role="closing"')
@@ -611,16 +765,24 @@ function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesign
611
765
  if (missingLayoutPreviews.length > 0) {
612
766
  warnings.push(`preview.html should mark layout fixtures with data-preview-layout; missing: ${missingLayoutPreviews.join(", ")}`)
613
767
  }
614
- const designText = hasDesignMd ? readFileSync(mdPath, "utf-8") : ""
615
- const tokenWarnings = designContractTokenWarnings(`${designText}\n${preview}`)
616
- warnings.push(...tokenWarnings)
617
768
  }
769
+ if (hasDesignCss) {
770
+ const designCss = readFileSync(cssPath, "utf-8")
771
+ const missingCoreClasses = requiredDesignCssClasses().filter((className) => !cssTextHasClassSelector(designCss, className))
772
+ if (missingCoreClasses.length > 0) errors.push(`design.css must style core template classes; missing: ${missingCoreClasses.map((item) => `.${item}`).join(", ")}`)
773
+ errors.push(...missingDesignCssAssetErrors(designCss, dir))
774
+ }
775
+ const designText = hasDesignMd ? readFileSync(mdPath, "utf-8") : ""
776
+ const designCss = hasDesignCss ? readFileSync(cssPath, "utf-8") : ""
777
+ const tokenWarnings = designContractTokenWarnings(`${designText}\n${designCss}`)
778
+ warnings.push(...tokenWarnings)
618
779
 
619
780
  return {
620
781
  ok: errors.length === 0,
621
782
  name,
622
783
  path: dir,
623
784
  hasDesignMd,
785
+ hasDesignCss,
624
786
  hasPreview,
625
787
  hasMarkers,
626
788
  sections,
@@ -641,15 +803,128 @@ function designContractTokenWarnings(text: string): string[] {
641
803
  { label: "surface", pattern: /--surface|surface token|border token|shadow token/i },
642
804
  ]
643
805
  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`)
806
+ if (!check.pattern.test(text)) warnings.push(`DESIGN.md/design.css should document ${check.label} design tokens or an equivalent contract`)
645
807
  }
646
808
  return warnings
647
809
  }
648
810
 
811
+ function previewReferencesDesignCss(preview: string): boolean {
812
+ return /<link\b[^>]*href=["'][^"']*design\.css["'][^>]*>/i.test(preview)
813
+ }
814
+
815
+ function requiredDesignCssClasses(): string[] {
816
+ return [
817
+ "slide-canvas",
818
+ "template-slide",
819
+ "template-frame",
820
+ "template-title",
821
+ "template-card",
822
+ "template-visual-slot-panel",
823
+ ]
824
+ }
825
+
826
+ function cssTextHasClassSelector(css: string, className: string): boolean {
827
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "")
828
+ const ruleRe = /([^{}]+)\{[^{}]*\}/g
829
+ let match: RegExpExecArray | null
830
+ while ((match = ruleRe.exec(withoutComments)) !== null) {
831
+ if (cssRuleHasClassSelector(match[1] ?? "", className)) return true
832
+ }
833
+ return false
834
+ }
835
+
836
+ function missingDesignCssAssetErrors(css: string, designDir: string): string[] {
837
+ const errors: string[] = []
838
+ const seen = new Set<string>()
839
+ const urlRe = /url\(\s*["']?([^"')]+)["']?\s*\)/gi
840
+ let match: RegExpExecArray | null
841
+ while ((match = urlRe.exec(css)) !== null) {
842
+ const raw = match[1].trim()
843
+ if (!raw || raw.startsWith("data:") || /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("#")) continue
844
+ const normalized = raw.replace(/^\.\/+/, "")
845
+ if (!normalized.startsWith("assets/")) continue
846
+ if (seen.has(normalized)) continue
847
+ seen.add(normalized)
848
+ if (!existsSync(join(designDir, ...normalized.split("/")))) errors.push(`design.css references missing asset: ${normalized}`)
849
+ }
850
+ return errors
851
+ }
852
+
853
+ function extractCssCodeBlocks(markdown: string): string[] {
854
+ const blocks: string[] = []
855
+ const fenceRe = /```css\n([\s\S]*?)```/g
856
+ let match: RegExpExecArray | null
857
+ while ((match = fenceRe.exec(markdown)) !== null) {
858
+ const body = match[1].trim()
859
+ if (body) blocks.push(body)
860
+ }
861
+ return blocks
862
+ }
863
+
649
864
  function designDraftDir(workspaceRoot: string, name: string): string {
650
865
  return resolve(workspaceRoot, ".revela", "drafts", "designs", name)
651
866
  }
652
867
 
868
+ function resolveDesignPreviewSourceDir(
869
+ workspaceRoot: string,
870
+ name: string,
871
+ requested?: "draft" | "installed" | "builtin",
872
+ ): { designDir: string; source: "draft" | "installed" | "builtin" } {
873
+ const draftDir = designDraftDir(workspaceRoot, name)
874
+ if (requested === "draft") {
875
+ if (!existsSync(draftDir)) throw new Error(`Design draft '${name}' does not exist: ${draftDir}`)
876
+ return { designDir: draftDir, source: "draft" }
877
+ }
878
+
879
+ const installedDir = resolveDesignDir(name)
880
+ if (requested === "installed" || requested === "builtin") {
881
+ if (!installedDir) throw new Error(`Design '${name}' is not installed`)
882
+ const source = requested === "builtin" ? "builtin" : installedDir.startsWith(SEED_DIR + sep) ? "builtin" : "installed"
883
+ return { designDir: installedDir, source }
884
+ }
885
+
886
+ if (existsSync(draftDir)) return { designDir: draftDir, source: "draft" }
887
+ if (!installedDir) throw new Error(`Design '${name}' is not available as a workspace draft or installed design`)
888
+ return { designDir: installedDir, source: installedDir.startsWith(SEED_DIR + sep) ? "builtin" : "installed" }
889
+ }
890
+
891
+ function readDesignCssFromDir(name: string, designDir: string): { css: string; warnings: string[] } {
892
+ const cssPath = join(designDir, "design.css")
893
+ if (existsSync(cssPath)) return { css: readFileSync(cssPath, "utf-8"), warnings: [] }
894
+
895
+ const mdPath = join(designDir, "DESIGN.md")
896
+ const markdown = existsSync(mdPath) ? readFileSync(mdPath, "utf-8") : ""
897
+ const cssBlocks = extractCssCodeBlocks(markdown)
898
+ const css = [
899
+ "/* Generated preview compatibility CSS from DESIGN.md. Add design.css to make this design CSS-native. */",
900
+ templateDeckCss(),
901
+ ...cssBlocks,
902
+ ].join("\n\n")
903
+ return {
904
+ css,
905
+ warnings: [`Design '${name}' has no design.css; generated preview CSS from DESIGN.md compatibility blocks.`],
906
+ }
907
+ }
908
+
909
+ function copyMissingBuiltInPreviewAssets(previewHtml: string, targetAssetsDir: string): void {
910
+ const fallbackAssetsDir = join(SEED_DIR, PREVIEW_FALLBACK_ASSET_DESIGN, "assets")
911
+ const assetRefs = new Set<string>()
912
+ const re = /(?:src|href)=["'](?:\.\/)?assets\/([^"']+)["']/g
913
+ let match: RegExpExecArray | null
914
+ while ((match = re.exec(previewHtml)) !== null) assetRefs.add(match[1])
915
+ if (assetRefs.size === 0) return
916
+
917
+ mkdirSync(targetAssetsDir, { recursive: true })
918
+ for (const rel of assetRefs) {
919
+ const target = resolve(targetAssetsDir, rel)
920
+ if (existsSync(target)) continue
921
+ const source = resolve(fallbackAssetsDir, rel)
922
+ if (!existsSync(source) || !statSync(source).isFile()) continue
923
+ mkdirSync(dirname(target), { recursive: true })
924
+ cpSync(source, target)
925
+ }
926
+ }
927
+
653
928
  function designDraftExists(workspaceRoot: string, name: string): boolean {
654
929
  const dir = designDraftDir(workspaceRoot, name)
655
930
  return existsSync(dir) && statSync(dir).isDirectory() && existsSync(join(dir, "DESIGN.md"))
@@ -812,6 +1087,12 @@ export interface DesignInventory {
812
1087
  sections: string[]
813
1088
  layouts: DesignInventoryLayout[]
814
1089
  components: DesignInventoryComponent[]
1090
+ pageTemplates: Array<{
1091
+ templateId: string
1092
+ slots: string[]
1093
+ requiredClasses: string[]
1094
+ contractNotes: string[]
1095
+ }>
815
1096
  assets: DesignPackageAssetInfo[]
816
1097
  hasMarkers: boolean
817
1098
  }
@@ -953,6 +1234,12 @@ export function getDesignInventory(designName?: string): DesignInventory {
953
1234
  nesting: inferComponentNesting(componentName),
954
1235
  contract: inferComponentContract(componentName, content),
955
1236
  })),
1237
+ pageTemplates: listPageTemplateVocabulary().map((template) => ({
1238
+ templateId: template.templateId,
1239
+ slots: template.slots.map((slot) => slot.name),
1240
+ requiredClasses: template.requiredClasses,
1241
+ contractNotes: template.contractNotes,
1242
+ })),
956
1243
  assets: listDesignAssetsInDir(designDir),
957
1244
  hasMarkers,
958
1245
  }
@@ -1225,6 +1512,7 @@ const UNIVERSAL_CLASSES = new Set([
1225
1512
  "overlay",
1226
1513
  "alt",
1227
1514
  "strong",
1515
+ ...PAGE_TEMPLATE_CLASSES,
1228
1516
  ])
1229
1517
 
1230
1518
  /**
@@ -1360,6 +1648,9 @@ export function extractDesignClasses(designName?: string): DesignClassVocabulary
1360
1648
  extractFromSection(content)
1361
1649
  }
1362
1650
 
1651
+ const cssPath = join(designDir, "design.css")
1652
+ if (existsSync(cssPath)) extractFromCss(readFileSync(cssPath, "utf-8"))
1653
+
1363
1654
  return { classes, prefixExemptions: DEFAULT_PREFIX_EXEMPTIONS }
1364
1655
  }
1365
1656
 
@@ -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
  }