@3xhaust/oh-my-design 0.16.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 (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +282 -0
  3. package/README.md +282 -0
  4. package/adapters/build.ts +184 -0
  5. package/adapters/claude.ts +111 -0
  6. package/adapters/codex.ts +68 -0
  7. package/adapters/tokens.ts +21 -0
  8. package/adapters/tool-map.json +7 -0
  9. package/bin/omd-install.ts +76 -0
  10. package/bin/omd.ts +1774 -0
  11. package/core/asset-sourcing/index.ts +187 -0
  12. package/core/coach/index.ts +98 -0
  13. package/core/composition/app-shell-workbench.md +168 -0
  14. package/core/composition/asymmetric-diagonal-grid.md +178 -0
  15. package/core/composition/bento-grid.md +223 -0
  16. package/core/composition/editorial-index-labels.md +173 -0
  17. package/core/composition/form-wizard-stepper.md +132 -0
  18. package/core/composition/master-detail-flow.md +129 -0
  19. package/core/composition/section-inversion.md +182 -0
  20. package/core/composition/sidebar-margin-annotation.md +184 -0
  21. package/core/composition/split-screen-hero.md +224 -0
  22. package/core/composition/sticky-sidebar-scroll.md +219 -0
  23. package/core/composition/typographic-hero.md +100 -0
  24. package/core/composition-contract/index.ts +233 -0
  25. package/core/composition-contract/visual-richness.ts +101 -0
  26. package/core/config/index.ts +27 -0
  27. package/core/copy/index.ts +296 -0
  28. package/core/craft/finish-pass.md +356 -0
  29. package/core/craft/index.ts +33 -0
  30. package/core/design/index.ts +581 -0
  31. package/core/design/interaction-states.ts +189 -0
  32. package/core/eval-harness/index.ts +225 -0
  33. package/core/evidence/final.ts +444 -0
  34. package/core/evidence/task.ts +459 -0
  35. package/core/figma/client.ts +312 -0
  36. package/core/figma/diff.ts +261 -0
  37. package/core/figma/responsive.ts +310 -0
  38. package/core/figma/system.ts +466 -0
  39. package/core/figma/types.ts +185 -0
  40. package/core/frame/check-ux.ts +180 -0
  41. package/core/frame/index.ts +39 -0
  42. package/core/frame/write.ts +168 -0
  43. package/core/graphics/css-illustration-primitives.md +189 -0
  44. package/core/graphics/duotone-image-presets.md +173 -0
  45. package/core/graphics/gradient-mesh.md +178 -0
  46. package/core/graphics/noise-grain-texture.md +150 -0
  47. package/core/graphics/placeholder-policy.md +185 -0
  48. package/core/graphics/svg-geometric-patterns.md +145 -0
  49. package/core/history/index.ts +39 -0
  50. package/core/install/detect.ts +32 -0
  51. package/core/install/install.ts +321 -0
  52. package/core/install/patch-claude.ts +109 -0
  53. package/core/install/patch-codex.ts +82 -0
  54. package/core/interaction/index.ts +101 -0
  55. package/core/interaction/recipes/signature-lighting.md +203 -0
  56. package/core/ir/dom.ts +352 -0
  57. package/core/ir/normalize.ts +177 -0
  58. package/core/motion/easing.md +151 -0
  59. package/core/motion/energy.ts +188 -0
  60. package/core/motion/recipes/image-hover-distortion.md +198 -0
  61. package/core/motion/recipes/magnetic-hover.md +229 -0
  62. package/core/motion/recipes/marquee.md +202 -0
  63. package/core/motion/recipes/number-counter.md +242 -0
  64. package/core/motion/recipes/page-loader.md +240 -0
  65. package/core/motion/recipes/parallax.md +233 -0
  66. package/core/motion/recipes/scroll-reveal.md +210 -0
  67. package/core/motion/recipes/section-color-inversion.md +217 -0
  68. package/core/motion/recipes/split-text-entrance.md +222 -0
  69. package/core/motion/recipes/stagger-orchestrator.md +227 -0
  70. package/core/motion/recipes/sticky-scene-transition.md +246 -0
  71. package/core/motion/recipes/view-transitions.md +239 -0
  72. package/core/probe/index.ts +186 -0
  73. package/core/probe/schema.json +46 -0
  74. package/core/protocol/composition-contract.md +192 -0
  75. package/core/protocol/copy-deck.md +96 -0
  76. package/core/protocol/human-design-loop.md +447 -0
  77. package/core/protocol/slop-review.md +71 -0
  78. package/core/ref/batch.ts +94 -0
  79. package/core/ref/blueprint.ts +199 -0
  80. package/core/ref/distance.ts +225 -0
  81. package/core/ref/invariants.ts +162 -0
  82. package/core/ref/signal.ts +37 -0
  83. package/core/ref/store.ts +122 -0
  84. package/core/render/index.ts +607 -0
  85. package/core/rules/attribution.ts +159 -0
  86. package/core/rules/builtin/contrast.yaml +8 -0
  87. package/core/rules/builtin/focus.yaml +8 -0
  88. package/core/rules/builtin/hit-area.yaml +8 -0
  89. package/core/rules/builtin/ko.yaml +6 -0
  90. package/core/rules/builtin/motion.yaml +80 -0
  91. package/core/rules/builtin/slop.yaml +365 -0
  92. package/core/rules/builtin/spacing.yaml +9 -0
  93. package/core/rules/builtin/system.yaml +42 -0
  94. package/core/rules/builtin/token.yaml +8 -0
  95. package/core/rules/builtin/ux.yaml +125 -0
  96. package/core/rules/engine.ts +88 -0
  97. package/core/rules/leakage.ts +72 -0
  98. package/core/rules/motion-spec.ts +204 -0
  99. package/core/site/index.ts +100 -0
  100. package/core/slop/index.ts +427 -0
  101. package/core/slop/text-slop.ts +220 -0
  102. package/core/source-seal/index.ts +174 -0
  103. package/core/stack/index.ts +53 -0
  104. package/core/target/index.ts +183 -0
  105. package/core/theory/color.md +285 -0
  106. package/core/theory/components.md +272 -0
  107. package/core/theory/craft.md +270 -0
  108. package/core/theory/expressive.md +213 -0
  109. package/core/theory/imagegen.md +105 -0
  110. package/core/theory/layout.md +159 -0
  111. package/core/theory/motion.md +283 -0
  112. package/core/theory/typography.md +134 -0
  113. package/core/theory/ux.md +580 -0
  114. package/core/theory/voice.md +595 -0
  115. package/core/types.ts +684 -0
  116. package/package.json +40 -0
  117. package/src/agents/composer.agent.yaml +174 -0
  118. package/src/agents/eye.agent.yaml +208 -0
  119. package/src/agents/framer.agent.yaml +52 -0
  120. package/src/agents/glance.agent.yaml +19 -0
  121. package/src/agents/hand.agent.yaml +200 -0
  122. package/src/agents/scout.agent.yaml +71 -0
  123. package/src/agents/sketch.agent.yaml +64 -0
  124. package/src/agents/typesetter.agent.yaml +52 -0
  125. package/src/agents/writer.agent.yaml +63 -0
  126. package/src/skills/omd-coach/SKILL.md +40 -0
  127. package/src/skills/omd-critique/SKILL.md +77 -0
  128. package/src/skills/omd-figma/SKILL.md +256 -0
  129. package/src/skills/omd-humanize/SKILL.md +160 -0
  130. package/src/skills/omd-scout/SKILL.md +77 -0
  131. package/src/skills/omd-ultradesign/SKILL.md +521 -0
@@ -0,0 +1,27 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ export type Checkpoint = 'none' | 'concept' | 'structure' | 'both';
5
+ export interface OmdConfig { checkpoint: Checkpoint }
6
+
7
+ const pathFor = (cwd: string): string => join(cwd, '.omd', 'config.json');
8
+
9
+ export function readConfig(cwd: string): OmdConfig {
10
+ const path = pathFor(cwd);
11
+ if (!existsSync(path)) return { checkpoint: 'none' };
12
+ const value = JSON.parse(readFileSync(path, 'utf8')) as Partial<OmdConfig>;
13
+ if (!['none', 'concept', 'structure', 'both'].includes(value.checkpoint ?? '')) {
14
+ throw new Error('invalid .omd/config.json checkpoint');
15
+ }
16
+ return { checkpoint: value.checkpoint! };
17
+ }
18
+
19
+ export function setCheckpoint(cwd: string, checkpoint: string): string {
20
+ if (!['none', 'concept', 'structure', 'both'].includes(checkpoint)) {
21
+ throw new Error('checkpoint must be none, concept, structure, or both');
22
+ }
23
+ const path = pathFor(cwd);
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ writeFileSync(path, `${JSON.stringify({ checkpoint }, null, 2)}\n`);
26
+ return path;
27
+ }
@@ -0,0 +1,296 @@
1
+ export const COPY_REQUIRED_SECTIONS = [
2
+ 'Sources and fact ledger',
3
+ 'Audience language',
4
+ 'Voice contract',
5
+ 'Surface copy',
6
+ 'Navigation and actions',
7
+ 'States and recovery',
8
+ 'Humanize audit',
9
+ ] as const;
10
+
11
+ export type InteractionScope = 'stateful' | 'navigation-only' | 'static';
12
+
13
+ export interface CopyViolation {
14
+ id: string;
15
+ path: '.omd/copy-deck.md';
16
+ message: string;
17
+ }
18
+
19
+ export interface CopyReviewViolation {
20
+ id: 'COPY-REVIEW-MISSING' | 'COPY-REVIEW-MODE' | 'COPY-REVIEW-TIME'
21
+ | 'COPY-REVIEW-HASH' | 'COPY-REVIEW-VERDICT' | 'COPY-REVIEW-FINDINGS';
22
+ path: '.omd/.cache/copy-eye.md';
23
+ message: string;
24
+ }
25
+
26
+ interface Fact {
27
+ id: string;
28
+ status: 'verified' | 'fixture' | 'open';
29
+ source: string;
30
+ }
31
+
32
+ const issue = (id: string, message: string): CopyViolation => ({
33
+ id,
34
+ path: '.omd/copy-deck.md',
35
+ message,
36
+ });
37
+
38
+ const reviewIssue = (id: CopyReviewViolation['id'], message: string): CopyReviewViolation => ({
39
+ id,
40
+ path: '.omd/.cache/copy-eye.md',
41
+ message,
42
+ });
43
+
44
+ function exactReviewFieldValues(md: string, label: string): string[] {
45
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
46
+ return [...md.matchAll(new RegExp(`^${escaped}:[ \\t]*(.*?)[ \\t]*$`, 'gm'))]
47
+ .map((match) => match[1]?.trim() ?? '');
48
+ }
49
+
50
+ /** Validate only the preserved copy-eye report structure; never infer blindness or semantic quality. */
51
+ export function validateCopyReviewReport(md: string): CopyReviewViolation[] {
52
+ if (!md.trim()) {
53
+ return [reviewIssue('COPY-REVIEW-MISSING', 'Copy-eye report is missing or empty.')];
54
+ }
55
+
56
+ const violations: CopyReviewViolation[] = [];
57
+ const modes = exactReviewFieldValues(md, 'Mode');
58
+ if (modes.length !== 1 || modes[0] !== 'copy-editor') {
59
+ violations.push(reviewIssue('COPY-REVIEW-MODE', 'Report must contain exactly one `Mode: copy-editor` line.'));
60
+ }
61
+
62
+ const times = exactReviewFieldValues(md, 'Review time');
63
+ const isoTime = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
64
+ if (times.length !== 1 || !isoTime.test(times[0] ?? '') || Number.isNaN(Date.parse(times[0] ?? ''))) {
65
+ violations.push(reviewIssue('COPY-REVIEW-TIME', 'Report must contain exactly one valid ISO `Review time` line.'));
66
+ }
67
+
68
+ const hashes = exactReviewFieldValues(md, 'Reviewed copy-deck SHA-256');
69
+ if (hashes.length !== 1 || !/^[0-9a-f]{64}$/.test(hashes[0] ?? '')) {
70
+ violations.push(reviewIssue('COPY-REVIEW-HASH', 'Report must contain exactly one reviewed copy-deck SHA-256 using 64 lowercase hex characters.'));
71
+ }
72
+
73
+ const verdicts = exactReviewFieldValues(md, 'Verdict');
74
+ if (verdicts.length !== 1 || !verdicts[0] || /^(?:TODO|TBD|N\/A)$/i.test(verdicts[0])) {
75
+ violations.push(reviewIssue('COPY-REVIEW-VERDICT', 'Report must contain exactly one non-empty `Verdict` line.'));
76
+ }
77
+
78
+ const findings = [...md.matchAll(/^Findings:[ \t]*(.*?)[ \t]*$/gm)];
79
+ if (findings.length !== 1) {
80
+ violations.push(reviewIssue('COPY-REVIEW-FINDINGS', 'Report must contain exactly one non-empty `Findings` section.'));
81
+ } else {
82
+ const match = findings[0]!;
83
+ const inline = match[1]?.trim() ?? '';
84
+ const afterHeader = md.slice((match.index ?? 0) + match[0].length)
85
+ .replace(/<!--[\s\S]*?-->/g, '')
86
+ .replace(/^[#*_`>|-]+\s*/gm, '')
87
+ .trim();
88
+ if (!(inline || afterHeader)) {
89
+ violations.push(reviewIssue('COPY-REVIEW-FINDINGS', 'Report must contain exactly one non-empty `Findings` section.'));
90
+ }
91
+ }
92
+
93
+ return violations;
94
+ }
95
+
96
+ export function parseCopySections(md: string): Map<string, string> {
97
+ const sections = new Map<string, string>();
98
+ for (const chunk of md.split(/^##\s+/m).slice(1)) {
99
+ const newline = chunk.indexOf('\n');
100
+ const heading = (newline === -1 ? chunk : chunk.slice(0, newline)).trim().toLowerCase();
101
+ if (heading) sections.set(heading, newline === -1 ? '' : chunk.slice(newline + 1));
102
+ }
103
+ return sections;
104
+ }
105
+
106
+ const hasContent = (body: string): boolean => body
107
+ .replace(/<!--[\s\S]*?-->/g, '')
108
+ .replace(/[#*_`>|-]/g, '')
109
+ .trim().length > 0;
110
+
111
+ function fieldValues(body: string, label: string): string[] {
112
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
113
+ const matches = body.matchAll(new RegExp(`^\\s*[-*]?\\s*(?:\\*\\*)?${escaped}(?:\\*\\*)?\\s*:\\s*(.+?)\\s*$`, 'gim'));
114
+ return [...matches].map((match) => match[1]?.trim() ?? '');
115
+ }
116
+
117
+ const field = (body: string, label: string): string | null => fieldValues(body, label)[0] ?? null;
118
+
119
+ const hasNaReason = (value: string | null): boolean => value !== null
120
+ && /^N\/A\s*(?:—|-|:)\s*\S.+$/i.test(value);
121
+
122
+ function parseFacts(body: string, violations: CopyViolation[]): Map<string, Fact> {
123
+ const facts = new Map<string, Fact>();
124
+ const rows = body.split('\n')
125
+ .filter((line) => line.trim().startsWith('|'))
126
+ .map((line) => line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => cell.trim()));
127
+ const header = rows.findIndex((cells) => cells.map((cell) => cell.toLowerCase()).includes('id'));
128
+ if (header === -1) {
129
+ violations.push(issue('COPY-FACT-LEDGER', 'Fact ledger must use ID, Status, Source, and Fact columns.'));
130
+ return facts;
131
+ }
132
+ const columns = rows[header]!.map((cell) => cell.toLowerCase());
133
+ const idIndex = columns.indexOf('id');
134
+ const statusIndex = columns.indexOf('status');
135
+ const sourceIndex = columns.indexOf('source');
136
+ const factIndex = columns.indexOf('fact');
137
+ if ([idIndex, statusIndex, sourceIndex, factIndex].some((index) => index === -1)) {
138
+ violations.push(issue('COPY-FACT-LEDGER', 'Fact ledger must use ID, Status, Source, and Fact columns.'));
139
+ return facts;
140
+ }
141
+ for (const cells of rows.slice(header + 1)) {
142
+ if (cells.every((cell) => /^:?-{3,}:?$/.test(cell))) continue;
143
+ const id = cells[idIndex] ?? '';
144
+ const status = (cells[statusIndex] ?? '').toLowerCase();
145
+ const source = cells[sourceIndex] ?? '';
146
+ const factText = cells[factIndex] ?? '';
147
+ if (!id && !status && !source && !factText) continue;
148
+ if (!/^[A-Z][A-Z0-9]*-\d+$/.test(id) || !['verified', 'fixture', 'open'].includes(status) || !source || /^(?:N\/A|none)$/i.test(source) || !factText) {
149
+ violations.push(issue('COPY-FACT-LEDGER', `Invalid fact row "${id || '(missing ID)'}"; require ID, verified|fixture|open status, source, and fact.`));
150
+ continue;
151
+ }
152
+ if (facts.has(id)) {
153
+ violations.push(issue('COPY-FACT-LEDGER', `Duplicate fact ID: ${id}.`));
154
+ continue;
155
+ }
156
+ facts.set(id, { id, status: status as Fact['status'], source });
157
+ }
158
+ if (facts.size === 0) violations.push(issue('COPY-FACT-LEDGER', 'Fact ledger must contain at least one explicit fact row.'));
159
+ return facts;
160
+ }
161
+
162
+ interface SurfaceBlock {
163
+ name: string;
164
+ body: string;
165
+ }
166
+
167
+ function parseSurfaceBlocks(body: string): SurfaceBlock[] {
168
+ return body.split(/^###\s+/m).slice(1).flatMap((chunk) => {
169
+ const newline = chunk.indexOf('\n');
170
+ const name = (newline === -1 ? chunk : chunk.slice(0, newline)).trim();
171
+ return name ? [{ name, body: newline === -1 ? '' : chunk.slice(newline + 1) }] : [];
172
+ });
173
+ }
174
+
175
+ function validateSurfaceCopy(body: string, facts: Map<string, Fact>, violations: CopyViolation[]): void {
176
+ const surfaces = parseSurfaceBlocks(body);
177
+ if (surfaces.length === 0) {
178
+ violations.push(issue('COPY-SURFACE', 'Surface copy must contain at least one H3 surface block.'));
179
+ return;
180
+ }
181
+
182
+ for (const surface of surfaces) {
183
+ for (const label of ['Main message', 'Supporting fact', 'Next action', 'Claim refs']) {
184
+ const values = fieldValues(surface.body, label);
185
+ if (values.length !== 1) {
186
+ violations.push(issue('COPY-SURFACE', `Surface "${surface.name}" must declare exactly one ${label} field.`));
187
+ }
188
+ }
189
+
190
+ const refs = fieldValues(surface.body, 'Claim refs');
191
+ if (refs.length !== 1) continue;
192
+ const value = refs[0]!;
193
+ if (/^none$/i.test(value)) continue;
194
+ if (!/^[A-Z][A-Z0-9]*-\d+(?:\s*,\s*[A-Z][A-Z0-9]*-\d+)*$/.test(value)) {
195
+ violations.push(issue('COPY-CLAIM-REF', `Surface "${surface.name}" has invalid Claim refs: ${value}. Use none or a comma-separated fact ID list.`));
196
+ continue;
197
+ }
198
+ const ids = value.split(',').map((id) => id.trim());
199
+ for (const id of ids) {
200
+ const fact = facts.get(id);
201
+ if (!fact) violations.push(issue('COPY-CLAIM-REF', `Surface "${surface.name}" references unknown fact ID ${id}.`));
202
+ else if (fact.status !== 'verified') {
203
+ violations.push(issue('COPY-CLAIM-REF', `Surface "${surface.name}" may reference only verified facts; ${id} is ${fact.status}.`));
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ function validateVoiceContract(body: string, violations: CopyViolation[]): void {
210
+ for (const label of ['Audience', 'Language', 'Register']) {
211
+ if (fieldValues(body, label).length !== 1) {
212
+ violations.push(issue('COPY-VOICE-CONTRACT', `Voice contract must declare exactly one ${label} field.`));
213
+ }
214
+ }
215
+ }
216
+
217
+ function validateInteraction(body: string, violations: CopyViolation[]): void {
218
+ const scopeValues = fieldValues(body, 'Interaction scope');
219
+ if (scopeValues.length !== 1) {
220
+ violations.push(issue('COPY-INTERACTION-SCOPE', 'States and recovery must declare exactly one Interaction scope field.'));
221
+ }
222
+ for (const label of ['Primary copy', 'Recovery copy', 'Primary probe', 'Recovery probe']) {
223
+ if (fieldValues(body, label).length !== 1) {
224
+ violations.push(issue('COPY-STATE-CONTRACT', `States and recovery must declare exactly one ${label} field.`));
225
+ }
226
+ }
227
+
228
+ const scopeValue = scopeValues[0] ?? null;
229
+ const scope = scopeValue as InteractionScope | null;
230
+ if (scopeValues.length !== 1 || !scope || !['stateful', 'navigation-only', 'static'].includes(scope)) {
231
+ if (scopeValues.length === 1) {
232
+ violations.push(issue('COPY-INTERACTION-SCOPE', 'Interaction scope must be exactly stateful, navigation-only, or static.'));
233
+ }
234
+ return;
235
+ }
236
+
237
+ const primaryCopy = field(body, 'Primary copy');
238
+ const recoveryCopy = field(body, 'Recovery copy');
239
+ const primaryProbe = field(body, 'Primary probe');
240
+ const recoveryProbe = field(body, 'Recovery probe');
241
+ if (!primaryCopy || /^N\/A\b/i.test(primaryCopy)) {
242
+ violations.push(issue('COPY-STATE-CONTRACT', 'Primary copy is required for every interaction scope.'));
243
+ }
244
+
245
+ if (scope === 'stateful') {
246
+ for (const [label, value] of [['Recovery copy', recoveryCopy], ['Primary probe', primaryProbe], ['Recovery probe', recoveryProbe]] as const) {
247
+ if (!value || /^N\/A\b/i.test(value)) violations.push(issue('COPY-STATE-CONTRACT', `${label} is required for stateful surfaces.`));
248
+ }
249
+ return;
250
+ }
251
+
252
+ if (scope === 'navigation-only') {
253
+ if (!primaryProbe || /^N\/A\b/i.test(primaryProbe)) {
254
+ violations.push(issue('COPY-STATE-CONTRACT', 'Primary probe is required for navigation-only surfaces.'));
255
+ }
256
+ for (const [label, value] of [['Recovery copy', recoveryCopy], ['Recovery probe', recoveryProbe]] as const) {
257
+ if (!hasNaReason(value)) violations.push(issue('COPY-STATE-CONTRACT', `${label} must be N/A with an explicit reason for navigation-only surfaces.`));
258
+ }
259
+ return;
260
+ }
261
+
262
+ for (const [label, value] of [['Recovery copy', recoveryCopy], ['Primary probe', primaryProbe], ['Recovery probe', recoveryProbe]] as const) {
263
+ if (!hasNaReason(value)) violations.push(issue('COPY-STATE-CONTRACT', `${label} must be N/A with an explicit reason for static surfaces.`));
264
+ }
265
+ }
266
+
267
+ /** Validate only explicit copy-deck structure and traceability; never score writing style. */
268
+ export function validateCopyDeck(md: string): CopyViolation[] {
269
+ const violations: CopyViolation[] = [];
270
+ if (!md.trim()) return [issue('COPY-MISSING', 'Copy deck is missing or empty.')];
271
+
272
+ const sections = parseCopySections(md);
273
+ for (const required of COPY_REQUIRED_SECTIONS) {
274
+ const body = sections.get(required.toLowerCase());
275
+ if (body === undefined) violations.push(issue('COPY-SECTION', `Missing required section: ${required}.`));
276
+ else if (!hasContent(body)) violations.push(issue('COPY-SECTION', `Required section is empty: ${required}.`));
277
+ }
278
+
279
+ const placeholders = [
280
+ /lorem ipsum/i,
281
+ /<!--\s*(?:TODO|TBD|PLACEHOLDER|OPEN QUESTION)\s*-->/i,
282
+ /(?:^|\n)\s*(?:(?:[-*]|#{1,6})\s*)?(?:TODO|TBD|\[(?:TODO|TBD|PLACEHOLDER|OPEN QUESTION)\])\s*(?=\n|$)/i,
283
+ /(?:^|\n)\s*(?:[-*]\s*)?(?:\*\*)?[^:\n]+?(?:\*\*)?\s*:\s*(?:TODO|TBD|\[(?:TODO|TBD|PLACEHOLDER|OPEN QUESTION)\])\s*(?=\n|$)/i,
284
+ /(?:^|\n)\s*[-*]?\s*(?:\*\*)?Open question(?:\*\*)?\s*:/i,
285
+ ];
286
+ if (placeholders.some((pattern) => pattern.test(md))) {
287
+ violations.push(issue('COPY-PLACEHOLDER', 'Resolve exact TODO/TBD/lorem/placeholder/open-question sentinels before build.'));
288
+ }
289
+
290
+ const factBody = sections.get('sources and fact ledger') ?? '';
291
+ const facts = parseFacts(factBody, violations);
292
+ validateVoiceContract(sections.get('voice contract') ?? '', violations);
293
+ validateSurfaceCopy(sections.get('surface copy') ?? '', facts, violations);
294
+ validateInteraction(sections.get('states and recovery') ?? '', violations);
295
+ return violations;
296
+ }