@cyber-dash-tech/revela 0.18.15 → 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 (103) hide show
  1. package/README.md +48 -45
  2. package/README.zh-CN.md +48 -45
  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 +108 -1
  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 +53 -9
  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 +22 -5
  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 +54 -9
  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 +18 -21
  53. package/lib/commands/designs-preview.ts +3 -8
  54. package/lib/deck-html/foundation.ts +8 -8
  55. package/lib/design/designs.ts +385 -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 +9 -5
  83. package/lib/qa/artifact.ts +117 -7
  84. package/lib/qa/checks.ts +1 -1
  85. package/lib/qa/compliance.ts +5 -1
  86. package/lib/qa/component-contracts.ts +90 -0
  87. package/lib/runtime/index.ts +99 -3
  88. package/package.json +7 -15
  89. package/plugins/revela/.codex-plugin/plugin.json +4 -4
  90. package/plugins/revela/hooks/revela_guard.ts +35 -0
  91. package/plugins/revela/hooks/revela_post_write_notice.ts +39 -9
  92. package/plugins/revela/mcp/revela-server.ts +103 -7
  93. package/plugins/revela/skills/revela/SKILL.md +3 -3
  94. package/plugins/revela/skills/revela-design/SKILL.md +25 -14
  95. package/plugins/revela/skills/revela-helper/SKILL.md +3 -3
  96. package/plugins/revela/skills/revela-make-deck/SKILL.md +27 -12
  97. package/plugins/revela/skills/revela-research/SKILL.md +1 -0
  98. package/skill/SKILL.md +11 -2
  99. package/designs/lucent/preview.html +0 -612
  100. package/designs/monet/preview.html +0 -2293
  101. package/designs/starter/preview.html +0 -314
  102. package/designs/summit/preview.html +0 -2284
  103. package/plugins/revela/skills/revela-review/SKILL.md +0 -46
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const agendaTemplate = templateModule("agenda")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const chartTakeawaysTemplate = templateModule("chart-takeaways")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const claimSupportingVisualTemplate = templateModule("claim-supporting-visual")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const closingTemplate = templateModule("closing")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const coverTemplate = templateModule("cover")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const executiveSummaryTemplate = templateModule("executive-summary")
4
+
@@ -0,0 +1,19 @@
1
+ import { listPageTemplateVocabulary } from "../vocabulary"
2
+
3
+ export const BUILTIN_PAGE_TEMPLATE_IDS = listPageTemplateVocabulary().map((template) => template.templateId)
4
+
5
+ export * from "./agenda"
6
+ export * from "./chart-takeaways"
7
+ export * from "./claim-supporting-visual"
8
+ export * from "./closing"
9
+ export * from "./cover"
10
+ export * from "./executive-summary"
11
+ export * from "./key-message-evidence"
12
+ export * from "./metric-highlight"
13
+ export * from "./problem-context"
14
+ export * from "./process-steps"
15
+ export * from "./recommendation-decision"
16
+ export * from "./risks-tradeoffs"
17
+ export * from "./section-divider"
18
+ export * from "./table-comparison"
19
+ export * from "./timeline-roadmap"
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const keyMessageEvidenceTemplate = templateModule("key-message-evidence")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const metricHighlightTemplate = templateModule("metric-highlight")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const problemContextTemplate = templateModule("problem-context")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const processStepsTemplate = templateModule("process-steps")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const recommendationDecisionTemplate = templateModule("recommendation-decision")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const risksTradeoffsTemplate = templateModule("risks-tradeoffs")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const sectionDividerTemplate = templateModule("section-divider")
4
+
@@ -0,0 +1,11 @@
1
+ import { getPageTemplateFoundation } from "../foundation"
2
+ import { getPageTemplateVocabulary } from "../vocabulary"
3
+
4
+ export function templateModule(templateId: string) {
5
+ return {
6
+ templateId,
7
+ foundation: () => getPageTemplateFoundation(templateId),
8
+ vocabulary: () => getPageTemplateVocabulary(templateId),
9
+ }
10
+ }
11
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const tableComparisonTemplate = templateModule("table-comparison")
4
+
@@ -0,0 +1,4 @@
1
+ import { templateModule } from "./shared"
2
+
3
+ export const timelineRoadmapTemplate = templateModule("timeline-roadmap")
4
+
@@ -0,0 +1,158 @@
1
+ export interface PageTemplateSlotVocabulary {
2
+ name: string
3
+ required: boolean
4
+ editable: boolean
5
+ replaceable: boolean
6
+ description: string
7
+ }
8
+
9
+ export interface PageTemplateVocabulary {
10
+ templateId: string
11
+ rootClasses: string[]
12
+ requiredClasses: string[]
13
+ optionalClasses: string[]
14
+ slots: PageTemplateSlotVocabulary[]
15
+ editableSlots: string[]
16
+ replaceableSlots: string[]
17
+ contractNotes: string[]
18
+ }
19
+
20
+ const sharedClasses = [
21
+ "template-slide",
22
+ "template-frame",
23
+ "template-eyebrow",
24
+ "template-title",
25
+ "template-body",
26
+ "template-grid",
27
+ "template-chart-layout",
28
+ "cols-2",
29
+ "cols-3",
30
+ "cols-4",
31
+ "template-card",
32
+ "template-list",
33
+ "template-hero",
34
+ "template-hero-title",
35
+ "template-hero--cover",
36
+ "template-hero--section-divider",
37
+ "template-hero--closing",
38
+ "template-frame--catalog",
39
+ "template-page-number",
40
+ "template-image-card",
41
+ "template-image-frame",
42
+ "template-image-caption",
43
+ "template-visual-placeholder",
44
+ "template-visual-placeholder-frame",
45
+ "template-visual-placeholder-label",
46
+ ]
47
+
48
+ export const PAGE_TEMPLATE_VOCABULARY: PageTemplateVocabulary[] = [
49
+ vocab("cover", ["template-hero"], ["hero"], ["hero"], ["Cover, divider, and closing templates use the hero frame; keep title hierarchy visible."]),
50
+ vocab("section-divider", ["template-hero"], ["hero"], ["hero"], ["Section divider uses the same hero-safe structure as cover."]),
51
+ vocab("closing", ["template-hero"], ["hero"], ["hero"], ["Closing uses the same hero-safe structure as cover."]),
52
+ vocab("agenda", ["template-agenda-panel"], ["agenda", "agenda-list"], ["agenda", "agenda-list"], ["Agenda numbers must remain in DOM order."]),
53
+ vocab("executive-summary", ["template-card"], ["summary-cards"], ["summary-cards"], ["Cards are editable; visual placeholders are optional and may become image/chart slots."]),
54
+ vocab("problem-context", ["template-card"], ["context", "supporting-points"], ["context", "supporting-points"], ["Context should stay separate from supporting bullets."]),
55
+ vocab("key-message-evidence", ["template-key-message-panel", "template-evidence-grid"], ["key-message", "evidence"], ["key-message", "evidence"], ["Key message and evidence regions must remain distinct."]),
56
+ vocab("claim-supporting-visual", ["template-claim-text-panel", "template-visual-slot-panel"], ["claim", "visual"], ["claim", "visual"], ["Visual slot may be replaced by image, chart, table, or diagram container."]),
57
+ vocab("metric-highlight", ["template-stat-grid"], ["metrics"], ["metrics", "insight"], ["Metric values should remain visible outside prose."]),
58
+ vocab("chart-takeaways", ["template-chart-panel", "template-chart-takeaway-panel"], ["visual", "takeaways"], ["visual", "takeaways"], ["Chart/image slot and takeaway text panel must both remain present."]),
59
+ vocab("table-comparison", ["template-table-wrap", "template-table"], ["table"], ["table", "insight"], ["Table headers and body should remain structured, not prose-only."]),
60
+ vocab("timeline-roadmap", ["template-timeline", "template-timeline-item", "template-timeline-dot", "template-timeline-copy", "template-insight-icon"], ["timeline"], ["timeline", "insight"], ["Each timeline item must keep dot and copy as sibling anchors inside one item.", "Horizontal timeline cards reuse .template-card; highlight uses the item modifier."]),
61
+ vocab("process-steps", ["template-steps", "template-step-number"], ["steps"], ["steps"], ["Steps should remain ordered in DOM order."]),
62
+ vocab("recommendation-decision", ["template-card"], ["recommendation", "rationale", "next-steps"], ["recommendation", "rationale", "next-steps"], ["Keep recommendation, rationale, and next steps separate."]),
63
+ vocab("risks-tradeoffs", ["template-card"], ["risks"], ["risks"], ["Risk/tradeoff cards should name uncertainty explicitly."]),
64
+ ]
65
+
66
+ const additionalClasses = [
67
+ "template-key-message-panel",
68
+ "template-key-message-kicker",
69
+ "template-evidence-grid",
70
+ "template-evidence-card",
71
+ "template-claim-text-panel",
72
+ "template-claim-text-title",
73
+ "template-claim-text-body",
74
+ "template-agenda-panel",
75
+ "template-agenda-inner",
76
+ "template-agenda-header",
77
+ "template-agenda-footer",
78
+ "template-agenda-list",
79
+ "template-agenda-item",
80
+ "template-stat-grid",
81
+ "template-stat-value",
82
+ "template-metric-layout",
83
+ "template-metric-layout--insight-top",
84
+ "template-metric-layout--insight-bottom",
85
+ "template-chart-panel",
86
+ "template-chart-placeholder",
87
+ "template-visual-slot-panel",
88
+ "template-visual-slot-label",
89
+ "template-chart-takeaway-panel",
90
+ "template-chart-takeaway-list",
91
+ "template-chart-takeaway-item",
92
+ "template-bar",
93
+ "template-table",
94
+ "template-table-wrap",
95
+ "template-side-panel",
96
+ "template-side-panel-title",
97
+ "template-side-panel-body",
98
+ "template-side-panel--left",
99
+ "template-side-panel--right",
100
+ "template-text-panel",
101
+ "template-text-panel-title",
102
+ "template-text-panel-body",
103
+ "template-insight-panel",
104
+ "template-insight-title",
105
+ "template-insight-icon",
106
+ "template-insight-body",
107
+ "template-timeline",
108
+ "template-timeline-layout",
109
+ "template-timeline-layout--left",
110
+ "template-timeline-layout--right",
111
+ "template-timeline--horizontal",
112
+ "template-timeline--vertical",
113
+ "template-timeline-item",
114
+ "template-timeline-item--highlight",
115
+ "template-timeline-dot",
116
+ "template-timeline-copy",
117
+ "template-timeline-date",
118
+ "template-steps",
119
+ "template-step-number",
120
+ "template-catalog-panel",
121
+ "template-catalog-kicker",
122
+ "template-catalog-title",
123
+ "template-catalog-grid",
124
+ "template-catalog-section",
125
+ "template-catalog-list",
126
+ ]
127
+
128
+ export const PAGE_TEMPLATE_CLASSES = [...new Set([...sharedClasses, ...additionalClasses])]
129
+
130
+ export function listPageTemplateVocabulary(): PageTemplateVocabulary[] {
131
+ return PAGE_TEMPLATE_VOCABULARY
132
+ }
133
+
134
+ export function getPageTemplateVocabulary(templateId: string): PageTemplateVocabulary {
135
+ const vocabulary = PAGE_TEMPLATE_VOCABULARY.find((item) => item.templateId === templateId)
136
+ if (!vocabulary) throw new Error(`Unknown page template vocabulary: ${templateId}`)
137
+ return vocabulary
138
+ }
139
+
140
+ function vocab(templateId: string, requiredClasses: string[], slotNames: string[], replaceableSlots: string[], contractNotes: string[]): PageTemplateVocabulary {
141
+ const slots = slotNames.map((name) => ({
142
+ name,
143
+ required: true,
144
+ editable: true,
145
+ replaceable: replaceableSlots.includes(name),
146
+ description: `${templateId} ${name} slot.`,
147
+ }))
148
+ return {
149
+ templateId,
150
+ rootClasses: ["template-slide", "template-frame", ...requiredClasses.slice(0, 1)],
151
+ requiredClasses,
152
+ optionalClasses: [],
153
+ slots,
154
+ editableSlots: slotNames,
155
+ replaceableSlots,
156
+ contractNotes,
157
+ }
158
+ }
@@ -82,12 +82,12 @@ export function buildPrompt(optionsOrDesignName?: BuildPromptOptions | string, l
82
82
  // Layer 1 — core skill for the selected prompt mode.
83
83
  const coreSkill = readFileSync(mode === "deck-render" ? SKILL_MD_PATH : NARRATIVE_SKILL_MD_PATH, "utf-8")
84
84
 
85
- // Check for preview.html
85
+ // Check for CSS-native design styling.
86
86
  const designDir = join(DESIGNS_DIR, design)
87
- const hasPreview = existsSync(join(designDir, "preview.html"))
88
- const previewLine = hasPreview
89
- ? "<!-- - preview.htmlcanonical visual reference (read this before generating slides) -->"
90
- : "<!-- - (no preview.html for this design) -->"
87
+ const hasDesignCss = existsSync(join(designDir, "design.css"))
88
+ const previewLine = hasDesignCss
89
+ ? "<!-- - design.cssexecutable design styling; generated previews use the built-in page template fixture -->"
90
+ : "<!-- - (no design.css for this design; compatibility CSS may be generated from DESIGN.md) -->"
91
91
 
92
92
  // Layer 2 — DOMAIN.md skill text (narrative mode only). Deck-render mode
93
93
  // renders the approved canonical narrative and must not re-interpret domain
@@ -190,6 +190,10 @@ function buildDesignLayer(designName: string): string {
190
190
  const componentIndex = generateComponentIndex(components)
191
191
  if (componentIndex) {
192
192
  layerParts.push(componentIndex)
193
+ layerParts.push([
194
+ "Components marked `✓` in the Contract column have required internal structure.",
195
+ "Fetch the component details before using them and preserve the required DOM/classes instead of hand-rolling a simpler lookalike.",
196
+ ].join(" "))
193
197
  }
194
198
 
195
199
  // 5. On-demand note
@@ -1,8 +1,13 @@
1
1
  import { formatDeckHtmlContractReport, validateDeckHtmlContract } from "../deck-html/contract"
2
- import type { DesignClassVocabulary } from "../design/designs"
2
+ import { activeDesign, extractDesignComponentContracts } from "../design/designs"
3
+ import type { DesignClassVocabulary, DesignComponentContract } from "../design/designs"
3
4
  import { formatReport, runQA } from "./index"
4
5
  import { runComplianceQA } from "./compliance"
6
+ import { runComponentContractQA } from "./component-contracts"
7
+ import { formatPageTemplateContractReport, validatePageTemplateContracts } from "../page-templates"
5
8
  import type { QAReport } from "./checks"
9
+ import { existsSync, readFileSync } from "fs"
10
+ import { basename, dirname, resolve } from "path"
6
11
 
7
12
  export interface ArtifactQAReport {
8
13
  file: string
@@ -24,6 +29,7 @@ export async function runArtifactQA(input: {
24
29
  workspaceRoot: string
25
30
  filePath: string
26
31
  vocabulary?: DesignClassVocabulary
32
+ componentContracts?: DesignComponentContract[]
27
33
  }): Promise<ArtifactQAReport> {
28
34
  const sections: string[] = []
29
35
  let hardErrorCount = 0
@@ -39,12 +45,42 @@ export async function runArtifactQA(input: {
39
45
  sections.push("**[deck HTML contract]**\n\n" + formatDeckHtmlContractReport(contract))
40
46
  }
41
47
 
42
- const compliance = runComplianceQA(input.filePath, input.vocabulary)
43
- const complianceErrors = hardErrors(compliance)
44
- if (compliance.totalIssues > 0) {
45
- hardErrorCount += complianceErrors
46
- warningCount += warnings(compliance)
47
- sections.push("**[component compliance]**\n\n" + formatReport(compliance))
48
+ const designCss = validateLinkedDesignCss(input.filePath)
49
+ if (designCss.errors.length > 0 || designCss.warnings.length > 0) {
50
+ hardErrorCount += designCss.errors.length
51
+ warningCount += designCss.warnings.length
52
+ sections.push("**[design CSS snapshot]**\n\n" + [
53
+ ...designCss.errors.map((message) => `- ERROR: ${message}`),
54
+ ...designCss.warnings.map((message) => `- WARNING: ${message}`),
55
+ ].join("\n"))
56
+ }
57
+
58
+ if (shouldRunArtifactCompliance(input.filePath)) {
59
+ const compliance = runComplianceQA(input.filePath, input.vocabulary)
60
+ const complianceErrors = hardErrors(compliance)
61
+ if (compliance.totalIssues > 0) {
62
+ hardErrorCount += complianceErrors
63
+ warningCount += warnings(compliance)
64
+ sections.push("**[component compliance]**\n\n" + formatReport(compliance))
65
+ }
66
+ }
67
+
68
+ const componentContracts = input.componentContracts ?? componentContractsForArtifact(input.filePath)
69
+ if (componentContracts.length > 0) {
70
+ const componentContractReport = runComponentContractQA(input.filePath, componentContracts)
71
+ const contractErrors = hardErrors(componentContractReport)
72
+ if (componentContractReport.totalIssues > 0) {
73
+ hardErrorCount += contractErrors
74
+ warningCount += warnings(componentContractReport)
75
+ sections.push("**[component structure contracts]**\n\n" + formatReport(componentContractReport))
76
+ }
77
+ }
78
+
79
+ const templateContracts = validatePageTemplateContracts(input.filePath)
80
+ if (templateContracts.issues.length > 0) {
81
+ hardErrorCount += templateContracts.issues.filter((issue) => issue.severity === "error").length
82
+ warningCount += templateContracts.issues.filter((issue) => issue.severity === "warning").length
83
+ sections.push("**[page template contracts]**\n\n" + formatPageTemplateContractReport(templateContracts))
48
84
  }
49
85
 
50
86
  try {
@@ -69,6 +105,80 @@ export async function runArtifactQA(input: {
69
105
  }
70
106
  }
71
107
 
108
+ function validateLinkedDesignCss(filePath: string): { errors: string[]; warnings: string[] } {
109
+ if (isDesignPreviewFile(filePath)) return { errors: [], warnings: [] }
110
+ const errors: string[] = []
111
+ const warnings: string[] = []
112
+ let html = ""
113
+ try {
114
+ html = readFileSync(filePath, "utf-8")
115
+ } catch {
116
+ return { errors, warnings }
117
+ }
118
+ const hrefs = [...html.matchAll(/<link\b[^>]*rel=["']stylesheet["'][^>]*href=["']([^"']*design\.css)["'][^>]*>/gi)].map((match) => match[1])
119
+ if (hrefs.length === 0) {
120
+ warnings.push("Deck does not reference a design.css snapshot.")
121
+ return { errors, warnings }
122
+ }
123
+ for (const href of hrefs) {
124
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) continue
125
+ const cssPath = resolve(dirname(filePath), href)
126
+ if (!existsSync(cssPath)) {
127
+ errors.push(`Linked design CSS is missing: ${href}`)
128
+ continue
129
+ }
130
+ const css = readFileSync(cssPath, "utf-8")
131
+ for (const asset of cssAssetUrls(css)) {
132
+ const assetPath = resolve(dirname(cssPath), asset)
133
+ if (!existsSync(assetPath)) errors.push(`Linked design CSS references missing asset: ${href} -> ${asset}`)
134
+ }
135
+ }
136
+ return { errors, warnings }
137
+ }
138
+
139
+ function cssAssetUrls(css: string): string[] {
140
+ const urls: string[] = []
141
+ const seen = new Set<string>()
142
+ const urlRe = /url\(\s*["']?([^"')]+)["']?\s*\)/gi
143
+ let match: RegExpExecArray | null
144
+ while ((match = urlRe.exec(css)) !== null) {
145
+ const raw = match[1].trim()
146
+ if (!raw || raw.startsWith("data:") || /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("#")) continue
147
+ if (seen.has(raw)) continue
148
+ seen.add(raw)
149
+ urls.push(raw)
150
+ }
151
+ return urls
152
+ }
153
+
154
+ function componentContractsForArtifact(filePath: string): DesignComponentContract[] {
155
+ const designName = designNameFromPreviewPath(filePath)
156
+ try {
157
+ return extractDesignComponentContracts(designName || activeDesign())
158
+ } catch {
159
+ return []
160
+ }
161
+ }
162
+
163
+ export function shouldRunArtifactCompliance(filePath: string): boolean {
164
+ return !isDesignPreviewFile(filePath)
165
+ }
166
+
167
+ function isDesignPreviewFile(filePath: string): boolean {
168
+ const normalizedPath = filePath.replace(/\\/g, "/")
169
+ if (basename(normalizedPath) !== "preview.html") return false
170
+ const parts = dirname(normalizedPath).split("/")
171
+ return parts.length >= 2 && parts[parts.length - 2] === "designs"
172
+ }
173
+
174
+ function designNameFromPreviewPath(filePath: string): string | undefined {
175
+ const normalizedPath = filePath.replace(/\\/g, "/")
176
+ if (basename(normalizedPath) !== "preview.html") return undefined
177
+ const parts = dirname(normalizedPath).split("/")
178
+ if (parts.length >= 2 && parts[parts.length - 2] === "designs") return parts[parts.length - 1]
179
+ return undefined
180
+ }
181
+
72
182
  export function formatArtifactQAReport(report: ArtifactQAReport): string {
73
183
  const heading = report.passed ? "Artifact QA: PASSED" : "Artifact QA: FAILED"
74
184
  const summary = `**File:** \`${report.file}\`\n\n**Hard errors:** ${report.hardErrorCount}\n**Warnings:** ${report.warningCount}`
package/lib/qa/checks.ts CHANGED
@@ -30,7 +30,7 @@ export interface LayoutIssue {
30
30
  | "centroid_offset" | "bottom_gap" | "sparse"
31
31
  | "height_mismatch" | "density_mismatch"
32
32
  | "gap_variance"
33
- | "unknown_class" | "novel_css_rule"
33
+ | "unknown_class" | "novel_css_rule" | "component_contract"
34
34
  | "remote_url" | "refine_proxy" | "missing_file"
35
35
  severity: IssueSeverity
36
36
  /** Human-readable description for the LLM to act on */
@@ -103,9 +103,13 @@ function extractCssDefinedClasses(html: string): ClassUse[] {
103
103
  while ((styleMatch = styleRe.exec(html)) !== null) {
104
104
  const styleBody = styleMatch[1]
105
105
  const bodyOffset = styleMatch.index + styleMatch[0].indexOf(styleBody)
106
+ const scanBody = styleBody
107
+ .replace(/url\([^)]*\)/gi, "url()")
108
+ .replace(/"[^"]*"/g, '""')
109
+ .replace(/'[^']*'/g, "''")
106
110
  classRe.lastIndex = 0
107
111
  let classMatch: RegExpExecArray | null
108
- while ((classMatch = classRe.exec(styleBody)) !== null) {
112
+ while ((classMatch = classRe.exec(scanBody)) !== null) {
109
113
  const cls = classMatch[1]
110
114
  if (classes.has(cls)) continue
111
115
 
@@ -0,0 +1,90 @@
1
+ import { readFileSync } from "fs"
2
+ import type { DesignComponentContract } from "../design/designs"
3
+ import type { LayoutIssue, QAReport, SlideReport } from "./checks"
4
+
5
+ function stripTags(value: string): string {
6
+ return value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
7
+ }
8
+
9
+ function extractTitle(html: string, index: number): string {
10
+ const match = /<(?:h1|h2|h3|title)\b[^>]*>([\s\S]*?)<\/(?:h1|h2|h3|title)>/i.exec(html)
11
+ const title = match ? stripTags(match[1]).slice(0, 80) : ""
12
+ return title || `Slide ${index + 1}`
13
+ }
14
+
15
+ function htmlHasClass(html: string, className: string): boolean {
16
+ const classAttrRe = /class\s*=\s*(["'])([\s\S]*?)\1/gi
17
+ let match: RegExpExecArray | null
18
+ while ((match = classAttrRe.exec(html)) !== null) {
19
+ if (match[2].split(/\s+/).includes(className)) return true
20
+ }
21
+ return false
22
+ }
23
+
24
+ function htmlUsesComponent(html: string, contract: DesignComponentContract): boolean {
25
+ if (new RegExp(`data-preview-component\\s*=\\s*["']${escapeRegExp(contract.component)}["']`, "i").test(html)) return true
26
+ return contract.requiredRootClasses.some((className) => htmlHasClass(html, className))
27
+ }
28
+
29
+ function escapeRegExp(value: string): string {
30
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
31
+ }
32
+
33
+ function validateContract(html: string, contract: DesignComponentContract): LayoutIssue[] {
34
+ if (!htmlUsesComponent(html, contract)) return []
35
+
36
+ const variantFailures: string[] = []
37
+ for (const variant of contract.variants) {
38
+ const missing = [
39
+ ...variant.requiredDescendantClasses.filter((className) => !htmlHasClass(html, className)),
40
+ ...(variant.repeatedItemClass && !htmlHasClass(html, variant.repeatedItemClass) ? [variant.repeatedItemClass] : []),
41
+ ...(variant.requiredItemClasses ?? []).filter((className) => !htmlHasClass(html, className)),
42
+ ...(variant.requireAlternatingClasses ?? []).filter((className) => !htmlHasClass(html, className)),
43
+ ]
44
+ if (missing.length === 0) return []
45
+ variantFailures.push(`${variant.name}: missing ${missing.join(", ")}`)
46
+ }
47
+
48
+ return [{
49
+ type: "compliance",
50
+ sub: "component_contract",
51
+ severity: "error",
52
+ detail: `Component \`${contract.component}\` does not satisfy its design structure contract. ${contract.guidance}`,
53
+ data: {
54
+ component: contract.component,
55
+ variants: variantFailures.join(" | "),
56
+ },
57
+ }]
58
+ }
59
+
60
+ function summarize(filePath: string, slides: SlideReport[]): QAReport {
61
+ const totalIssues = slides.reduce((sum, slide) => sum + slide.issues.length, 0)
62
+ const errorCount = slides.reduce((sum, slide) => sum + slide.issues.filter((issue) => issue.severity === "error").length, 0)
63
+ const warningCount = slides.reduce((sum, slide) => sum + slide.issues.filter((issue) => issue.severity === "warning").length, 0)
64
+ const summary = totalIssues === 0
65
+ ? "All component structure contracts passed."
66
+ : `Found ${totalIssues} component contract issue(s): ${errorCount} error(s), ${warningCount} warning(s).`
67
+ return { file: filePath, slides, totalIssues, errorCount, warningCount, summary }
68
+ }
69
+
70
+ export function runComponentContractQA(htmlFilePath: string, contracts: DesignComponentContract[]): QAReport {
71
+ const html = readFileSync(htmlFilePath, "utf-8")
72
+ const sectionRe = /<section\b[\s\S]*?<\/section>/gi
73
+ const slides: SlideReport[] = []
74
+ let match: RegExpExecArray | null
75
+ let index = 0
76
+
77
+ while ((match = sectionRe.exec(html)) !== null) {
78
+ const chunk = match[0]
79
+ const issues = contracts.flatMap((contract) => validateContract(chunk, contract))
80
+ slides.push({ index, title: extractTitle(chunk, index), issues })
81
+ index++
82
+ }
83
+
84
+ if (slides.length === 0) {
85
+ const issues = contracts.flatMap((contract) => validateContract(html, contract))
86
+ slides.push({ index: 0, title: extractTitle(html, 0), issues })
87
+ }
88
+
89
+ return summarize(htmlFilePath, slides)
90
+ }