@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.
- package/LICENSE +21 -0
- package/README.ko.md +282 -0
- package/README.md +282 -0
- package/adapters/build.ts +184 -0
- package/adapters/claude.ts +111 -0
- package/adapters/codex.ts +68 -0
- package/adapters/tokens.ts +21 -0
- package/adapters/tool-map.json +7 -0
- package/bin/omd-install.ts +76 -0
- package/bin/omd.ts +1774 -0
- package/core/asset-sourcing/index.ts +187 -0
- package/core/coach/index.ts +98 -0
- package/core/composition/app-shell-workbench.md +168 -0
- package/core/composition/asymmetric-diagonal-grid.md +178 -0
- package/core/composition/bento-grid.md +223 -0
- package/core/composition/editorial-index-labels.md +173 -0
- package/core/composition/form-wizard-stepper.md +132 -0
- package/core/composition/master-detail-flow.md +129 -0
- package/core/composition/section-inversion.md +182 -0
- package/core/composition/sidebar-margin-annotation.md +184 -0
- package/core/composition/split-screen-hero.md +224 -0
- package/core/composition/sticky-sidebar-scroll.md +219 -0
- package/core/composition/typographic-hero.md +100 -0
- package/core/composition-contract/index.ts +233 -0
- package/core/composition-contract/visual-richness.ts +101 -0
- package/core/config/index.ts +27 -0
- package/core/copy/index.ts +296 -0
- package/core/craft/finish-pass.md +356 -0
- package/core/craft/index.ts +33 -0
- package/core/design/index.ts +581 -0
- package/core/design/interaction-states.ts +189 -0
- package/core/eval-harness/index.ts +225 -0
- package/core/evidence/final.ts +444 -0
- package/core/evidence/task.ts +459 -0
- package/core/figma/client.ts +312 -0
- package/core/figma/diff.ts +261 -0
- package/core/figma/responsive.ts +310 -0
- package/core/figma/system.ts +466 -0
- package/core/figma/types.ts +185 -0
- package/core/frame/check-ux.ts +180 -0
- package/core/frame/index.ts +39 -0
- package/core/frame/write.ts +168 -0
- package/core/graphics/css-illustration-primitives.md +189 -0
- package/core/graphics/duotone-image-presets.md +173 -0
- package/core/graphics/gradient-mesh.md +178 -0
- package/core/graphics/noise-grain-texture.md +150 -0
- package/core/graphics/placeholder-policy.md +185 -0
- package/core/graphics/svg-geometric-patterns.md +145 -0
- package/core/history/index.ts +39 -0
- package/core/install/detect.ts +32 -0
- package/core/install/install.ts +321 -0
- package/core/install/patch-claude.ts +109 -0
- package/core/install/patch-codex.ts +82 -0
- package/core/interaction/index.ts +101 -0
- package/core/interaction/recipes/signature-lighting.md +203 -0
- package/core/ir/dom.ts +352 -0
- package/core/ir/normalize.ts +177 -0
- package/core/motion/easing.md +151 -0
- package/core/motion/energy.ts +188 -0
- package/core/motion/recipes/image-hover-distortion.md +198 -0
- package/core/motion/recipes/magnetic-hover.md +229 -0
- package/core/motion/recipes/marquee.md +202 -0
- package/core/motion/recipes/number-counter.md +242 -0
- package/core/motion/recipes/page-loader.md +240 -0
- package/core/motion/recipes/parallax.md +233 -0
- package/core/motion/recipes/scroll-reveal.md +210 -0
- package/core/motion/recipes/section-color-inversion.md +217 -0
- package/core/motion/recipes/split-text-entrance.md +222 -0
- package/core/motion/recipes/stagger-orchestrator.md +227 -0
- package/core/motion/recipes/sticky-scene-transition.md +246 -0
- package/core/motion/recipes/view-transitions.md +239 -0
- package/core/probe/index.ts +186 -0
- package/core/probe/schema.json +46 -0
- package/core/protocol/composition-contract.md +192 -0
- package/core/protocol/copy-deck.md +96 -0
- package/core/protocol/human-design-loop.md +447 -0
- package/core/protocol/slop-review.md +71 -0
- package/core/ref/batch.ts +94 -0
- package/core/ref/blueprint.ts +199 -0
- package/core/ref/distance.ts +225 -0
- package/core/ref/invariants.ts +162 -0
- package/core/ref/signal.ts +37 -0
- package/core/ref/store.ts +122 -0
- package/core/render/index.ts +607 -0
- package/core/rules/attribution.ts +159 -0
- package/core/rules/builtin/contrast.yaml +8 -0
- package/core/rules/builtin/focus.yaml +8 -0
- package/core/rules/builtin/hit-area.yaml +8 -0
- package/core/rules/builtin/ko.yaml +6 -0
- package/core/rules/builtin/motion.yaml +80 -0
- package/core/rules/builtin/slop.yaml +365 -0
- package/core/rules/builtin/spacing.yaml +9 -0
- package/core/rules/builtin/system.yaml +42 -0
- package/core/rules/builtin/token.yaml +8 -0
- package/core/rules/builtin/ux.yaml +125 -0
- package/core/rules/engine.ts +88 -0
- package/core/rules/leakage.ts +72 -0
- package/core/rules/motion-spec.ts +204 -0
- package/core/site/index.ts +100 -0
- package/core/slop/index.ts +427 -0
- package/core/slop/text-slop.ts +220 -0
- package/core/source-seal/index.ts +174 -0
- package/core/stack/index.ts +53 -0
- package/core/target/index.ts +183 -0
- package/core/theory/color.md +285 -0
- package/core/theory/components.md +272 -0
- package/core/theory/craft.md +270 -0
- package/core/theory/expressive.md +213 -0
- package/core/theory/imagegen.md +105 -0
- package/core/theory/layout.md +159 -0
- package/core/theory/motion.md +283 -0
- package/core/theory/typography.md +134 -0
- package/core/theory/ux.md +580 -0
- package/core/theory/voice.md +595 -0
- package/core/types.ts +684 -0
- package/package.json +40 -0
- package/src/agents/composer.agent.yaml +174 -0
- package/src/agents/eye.agent.yaml +208 -0
- package/src/agents/framer.agent.yaml +52 -0
- package/src/agents/glance.agent.yaml +19 -0
- package/src/agents/hand.agent.yaml +200 -0
- package/src/agents/scout.agent.yaml +71 -0
- package/src/agents/sketch.agent.yaml +64 -0
- package/src/agents/typesetter.agent.yaml +52 -0
- package/src/agents/writer.agent.yaml +63 -0
- package/src/skills/omd-coach/SKILL.md +40 -0
- package/src/skills/omd-critique/SKILL.md +77 -0
- package/src/skills/omd-figma/SKILL.md +256 -0
- package/src/skills/omd-humanize/SKILL.md +160 -0
- package/src/skills/omd-scout/SKILL.md +77 -0
- package/src/skills/omd-ultradesign/SKILL.md +521 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { Violation } from '../types.ts';
|
|
4
|
+
|
|
5
|
+
// ── Section schema ────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The fourteen required sections of a `.omd/design.md` contract.
|
|
9
|
+
*
|
|
10
|
+
* Section names are matched case-insensitively against the document's h2
|
|
11
|
+
* headings (## Heading). The order is canonical but the validator does not
|
|
12
|
+
* enforce order — only presence.
|
|
13
|
+
*/
|
|
14
|
+
export const REQUIRED_SECTIONS = [
|
|
15
|
+
'Source of truth',
|
|
16
|
+
'Brand',
|
|
17
|
+
'Product goals',
|
|
18
|
+
'Personas & jobs',
|
|
19
|
+
'Information architecture',
|
|
20
|
+
'Design principles',
|
|
21
|
+
'Visual language',
|
|
22
|
+
'Components',
|
|
23
|
+
'Accessibility',
|
|
24
|
+
'Responsive behavior',
|
|
25
|
+
'Interaction states',
|
|
26
|
+
'Content voice',
|
|
27
|
+
'Implementation constraints',
|
|
28
|
+
'Open questions',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export type DesignSection = (typeof REQUIRED_SECTIONS)[number];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The six interaction states that must be enumerated in the "Interaction states"
|
|
35
|
+
* section. Each state is either implemented or explicitly skipped with a reason.
|
|
36
|
+
* The validator flags when the section exists but none of these words appear.
|
|
37
|
+
*/
|
|
38
|
+
export const INTERACTION_STATES = [
|
|
39
|
+
'loading',
|
|
40
|
+
'empty',
|
|
41
|
+
'error',
|
|
42
|
+
'success',
|
|
43
|
+
'disabled',
|
|
44
|
+
'offline',
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export type InteractionState = (typeof INTERACTION_STATES)[number];
|
|
48
|
+
|
|
49
|
+
// ── Evidence scanner ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export interface Evidence {
|
|
52
|
+
hasPackageJson: boolean;
|
|
53
|
+
packageJsonReadable: boolean;
|
|
54
|
+
framework: string | null;
|
|
55
|
+
dependencies: string[];
|
|
56
|
+
hasThemeTokens: boolean;
|
|
57
|
+
tokenFilePaths: string[];
|
|
58
|
+
surfaceCount: number;
|
|
59
|
+
surfacePaths: string[];
|
|
60
|
+
appEvidencePaths: string[];
|
|
61
|
+
frameMd: string | null;
|
|
62
|
+
decisionsMd: string | null;
|
|
63
|
+
attributionMd: string | null;
|
|
64
|
+
captureCount: number;
|
|
65
|
+
captureNames: string[];
|
|
66
|
+
hasMotionSpec: boolean;
|
|
67
|
+
hasVoiceStudy: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Scan the project at `cwd` for design evidence. Returns everything that can
|
|
72
|
+
* be inferred without rendering a page. Unknowns stay null; the generator
|
|
73
|
+
* converts them to open questions rather than invented values.
|
|
74
|
+
*/
|
|
75
|
+
export function discoverEvidence(cwd: string): Evidence {
|
|
76
|
+
const evidence: Evidence = {
|
|
77
|
+
hasPackageJson: false,
|
|
78
|
+
packageJsonReadable: false,
|
|
79
|
+
framework: null,
|
|
80
|
+
dependencies: [],
|
|
81
|
+
hasThemeTokens: false,
|
|
82
|
+
tokenFilePaths: [],
|
|
83
|
+
surfaceCount: 0,
|
|
84
|
+
surfacePaths: [],
|
|
85
|
+
appEvidencePaths: [],
|
|
86
|
+
frameMd: null,
|
|
87
|
+
decisionsMd: null,
|
|
88
|
+
attributionMd: null,
|
|
89
|
+
captureCount: 0,
|
|
90
|
+
captureNames: [],
|
|
91
|
+
hasMotionSpec: false,
|
|
92
|
+
hasVoiceStudy: false,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// package.json — framework and dependency fingerprint
|
|
96
|
+
const pkgPath = join(cwd, 'package.json');
|
|
97
|
+
if (existsSync(pkgPath)) {
|
|
98
|
+
evidence.hasPackageJson = true;
|
|
99
|
+
try {
|
|
100
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
|
|
101
|
+
dependencies?: Record<string, string>;
|
|
102
|
+
devDependencies?: Record<string, string>;
|
|
103
|
+
};
|
|
104
|
+
const all = {
|
|
105
|
+
...pkg.dependencies,
|
|
106
|
+
...pkg.devDependencies,
|
|
107
|
+
};
|
|
108
|
+
const names = Object.keys(all);
|
|
109
|
+
evidence.packageJsonReadable = true;
|
|
110
|
+
evidence.dependencies = names;
|
|
111
|
+
|
|
112
|
+
if (names.some((n) => n === 'next' || n === '@next/core-web-vitals')) evidence.framework = 'next';
|
|
113
|
+
else if (names.includes('react')) evidence.framework = 'react';
|
|
114
|
+
else if (names.includes('vue')) evidence.framework = 'vue';
|
|
115
|
+
else if (names.some((n) => n === 'svelte' || n === '@sveltejs/kit')) evidence.framework = 'svelte';
|
|
116
|
+
} catch { /* corrupt package.json — skip */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fixed, high-signal application entries and build configs. These are evidence that a
|
|
120
|
+
// repository is not blank even when it has no package.json or conventional page file yet.
|
|
121
|
+
const appEvidenceCandidates = [
|
|
122
|
+
'src/main.js', 'src/main.ts', 'src/main.jsx', 'src/main.tsx',
|
|
123
|
+
'src/App.js', 'src/App.ts', 'src/App.jsx', 'src/App.tsx',
|
|
124
|
+
'src/app.js', 'src/app.ts', 'src/app.jsx', 'src/app.tsx',
|
|
125
|
+
'app.js', 'app.ts', 'app.jsx', 'app.tsx',
|
|
126
|
+
'main.js', 'main.ts', 'main.jsx', 'main.tsx',
|
|
127
|
+
'vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs',
|
|
128
|
+
'webpack.config.js', 'webpack.config.ts', 'rollup.config.js', 'rollup.config.ts',
|
|
129
|
+
'next.config.js', 'next.config.mjs', 'next.config.ts',
|
|
130
|
+
'nuxt.config.js', 'nuxt.config.ts', 'svelte.config.js', 'svelte.config.ts',
|
|
131
|
+
'astro.config.js', 'astro.config.mjs', 'astro.config.ts',
|
|
132
|
+
'vue.config.js', 'angular.json', 'gatsby-config.js', 'gatsby-config.ts',
|
|
133
|
+
];
|
|
134
|
+
evidence.appEvidencePaths = appEvidenceCandidates.filter((rel) => existsSync(join(cwd, rel)));
|
|
135
|
+
|
|
136
|
+
// Token / theme files — CSS custom properties, tailwind config
|
|
137
|
+
const tokenCandidates = [
|
|
138
|
+
'src/styles/tokens.css',
|
|
139
|
+
'src/styles/variables.css',
|
|
140
|
+
'src/tokens.css',
|
|
141
|
+
'tokens.css',
|
|
142
|
+
'styles/tokens.css',
|
|
143
|
+
'tailwind.config.js',
|
|
144
|
+
'tailwind.config.ts',
|
|
145
|
+
'tailwind.config.cjs',
|
|
146
|
+
'theme.ts',
|
|
147
|
+
'theme.js',
|
|
148
|
+
'src/theme.ts',
|
|
149
|
+
'src/theme.js',
|
|
150
|
+
];
|
|
151
|
+
for (const rel of tokenCandidates) {
|
|
152
|
+
const abs = join(cwd, rel);
|
|
153
|
+
if (existsSync(abs)) {
|
|
154
|
+
evidence.hasThemeTokens = true;
|
|
155
|
+
evidence.tokenFilePaths.push(rel);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Surface discovery — count HTML/page/route files as a proxy for surface count
|
|
160
|
+
const surfaceDirs = ['src/app', 'src/pages', 'pages', 'src/routes', 'routes'];
|
|
161
|
+
for (const dir of surfaceDirs) {
|
|
162
|
+
const abs = join(cwd, dir);
|
|
163
|
+
if (!existsSync(abs)) continue;
|
|
164
|
+
try {
|
|
165
|
+
const files = readdirSync(abs, { recursive: true }) as string[];
|
|
166
|
+
const pages = files.filter((f) =>
|
|
167
|
+
f.endsWith('.tsx') || f.endsWith('.jsx') ||
|
|
168
|
+
f.endsWith('.vue') || f.endsWith('.svelte') ||
|
|
169
|
+
f.endsWith('.html'),
|
|
170
|
+
);
|
|
171
|
+
if (pages.length > 0) {
|
|
172
|
+
evidence.surfacePaths.push(...pages.slice(0, 20).map((f) => `${dir}/${f}`));
|
|
173
|
+
evidence.surfaceCount += pages.length;
|
|
174
|
+
}
|
|
175
|
+
} catch { /* unreadable — skip */ }
|
|
176
|
+
}
|
|
177
|
+
// Fallback: count HTML files at root and dist/
|
|
178
|
+
if (evidence.surfaceCount === 0) {
|
|
179
|
+
for (const dir of [cwd, join(cwd, 'dist'), join(cwd, 'public')]) {
|
|
180
|
+
if (!existsSync(dir)) continue;
|
|
181
|
+
try {
|
|
182
|
+
const files = readdirSync(dir);
|
|
183
|
+
const htmlFiles = files.filter((f) => f.endsWith('.html') || f.endsWith('.htm'));
|
|
184
|
+
evidence.surfaceCount += htmlFiles.length;
|
|
185
|
+
evidence.surfacePaths.push(...htmlFiles.map((f) => dir === cwd ? f : `${join(dir, f).slice(cwd.length + 1)}`));
|
|
186
|
+
} catch { /* skip */ }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// .omd/ artifacts
|
|
191
|
+
const omdDir = join(cwd, '.omd');
|
|
192
|
+
const framePath = join(omdDir, 'frame.md');
|
|
193
|
+
if (existsSync(framePath)) evidence.frameMd = readFileSync(framePath, 'utf8');
|
|
194
|
+
|
|
195
|
+
const decisionsPath = join(omdDir, 'decisions.md');
|
|
196
|
+
if (existsSync(decisionsPath)) evidence.decisionsMd = readFileSync(decisionsPath, 'utf8');
|
|
197
|
+
|
|
198
|
+
const attrPath = join(omdDir, 'attribution.md');
|
|
199
|
+
if (existsSync(attrPath)) evidence.attributionMd = readFileSync(attrPath, 'utf8');
|
|
200
|
+
|
|
201
|
+
const motionSpecPath = join(omdDir, 'motion-spec.md');
|
|
202
|
+
if (existsSync(motionSpecPath)) evidence.hasMotionSpec = true;
|
|
203
|
+
|
|
204
|
+
const voiceStudyPath = join(omdDir, 'voice-study.md');
|
|
205
|
+
if (existsSync(voiceStudyPath)) evidence.hasVoiceStudy = true;
|
|
206
|
+
|
|
207
|
+
const refsDir = join(omdDir, 'refs');
|
|
208
|
+
if (existsSync(refsDir)) {
|
|
209
|
+
try {
|
|
210
|
+
const jsonFiles = readdirSync(refsDir).filter((f) => f.endsWith('.json'));
|
|
211
|
+
evidence.captureCount = jsonFiles.length;
|
|
212
|
+
evidence.captureNames = jsonFiles.map((f) => f.slice(0, -5));
|
|
213
|
+
} catch { /* skip */ }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return evidence;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Design.md generator ───────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Generate the initial `.omd/design.md` content from discovered evidence.
|
|
223
|
+
*
|
|
224
|
+
* The file is an English-language design contract. Every field that cannot be
|
|
225
|
+
* inferred from evidence becomes an explicit open question rather than an
|
|
226
|
+
* invented value. A bare project with no evidence produces a skeleton that the
|
|
227
|
+
* designer fills in; a project with frame.md and token files produces a
|
|
228
|
+
* partially-populated contract.
|
|
229
|
+
*/
|
|
230
|
+
export function generateDesignMd(evidence: Evidence): string {
|
|
231
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
232
|
+
|
|
233
|
+
const surfaceSummary = evidence.surfaceCount > 0
|
|
234
|
+
? `${evidence.surfaceCount} surface${evidence.surfaceCount === 1 ? '' : 's'} detected: ${evidence.surfacePaths.slice(0, 5).join(', ')}${evidence.surfaceCount > 5 ? ` (+${evidence.surfaceCount - 5} more)` : ''}`
|
|
235
|
+
: 'No existing surface detected';
|
|
236
|
+
|
|
237
|
+
const frameSummary = evidence.frameMd
|
|
238
|
+
? `Derived from .omd/frame.md:\n ${evidence.frameMd.split('\n').slice(0, 3).join('\n ')}`
|
|
239
|
+
: 'Open question — run `omd frame set` to establish the problem statement';
|
|
240
|
+
|
|
241
|
+
const frameworkNote = evidence.framework
|
|
242
|
+
? `${evidence.framework} (detected via package.json)`
|
|
243
|
+
: evidence.hasPackageJson
|
|
244
|
+
? `Unrecognised package/toolchain (${evidence.packageJsonReadable ? 'package.json inspected' : 'package.json unreadable'})`
|
|
245
|
+
: evidence.surfaceCount > 0
|
|
246
|
+
? 'Existing surface without package.json'
|
|
247
|
+
: evidence.appEvidencePaths.length > 0
|
|
248
|
+
? `Existing application/toolchain evidence: ${evidence.appEvidencePaths.join(', ')}`
|
|
249
|
+
: 'No existing repository stack detected';
|
|
250
|
+
|
|
251
|
+
// The generator has repository evidence, not the user's brief. It therefore records
|
|
252
|
+
// the build-time precedence instead of pretending to know whether the brief overrides it.
|
|
253
|
+
const stackPolicy = evidence.surfaceCount > 0
|
|
254
|
+
? 'Preserve the existing surface and repository toolchain; inspect before changing it and do not replace it with a new scaffold.'
|
|
255
|
+
: evidence.framework
|
|
256
|
+
? `Preserve the detected ${evidence.framework} repository stack/toolchain.`
|
|
257
|
+
: evidence.hasPackageJson
|
|
258
|
+
? 'Inspect and preserve the existing unrecognised package/toolchain; do not replace it with React or another scaffold.'
|
|
259
|
+
: evidence.appEvidencePaths.length > 0
|
|
260
|
+
? 'Inspect and preserve the existing application/toolchain evidence; do not replace it with a new scaffold.'
|
|
261
|
+
: 'React + Vite + TypeScript default unless explicit brief override. Plain HTML requires an explicit user request.';
|
|
262
|
+
|
|
263
|
+
const dependencyPolicy = evidence.surfaceCount > 0 || evidence.hasPackageJson || evidence.appEvidencePaths.length > 0
|
|
264
|
+
? 'Do not add unnecessary dependencies to this existing project.'
|
|
265
|
+
: 'Greenfield scaffold dependencies for React + Vite + TypeScript are allowed.';
|
|
266
|
+
|
|
267
|
+
const tokenNote = evidence.hasThemeTokens
|
|
268
|
+
? `Token files found: ${evidence.tokenFilePaths.join(', ')}`
|
|
269
|
+
: 'No token files found — define `:root` CSS custom properties or a theme config';
|
|
270
|
+
|
|
271
|
+
const referenceNote = evidence.captureCount > 0
|
|
272
|
+
? `${evidence.captureCount} reference capture${evidence.captureCount === 1 ? '' : 's'}: ${evidence.captureNames.slice(0, 5).join(', ')}${evidence.captureCount > 5 ? ` (+${evidence.captureCount - 5} more)` : ''}`
|
|
273
|
+
: 'No references captured yet — run `omd ref add` to populate the board';
|
|
274
|
+
|
|
275
|
+
return `# Design contract
|
|
276
|
+
|
|
277
|
+
> This file is the persistent design contract for this project. It is the upstream
|
|
278
|
+
> authority for every surface decision. Established by \`omd design\`, maintained by
|
|
279
|
+
> the designer. Everything under \`.omd/\` is English; this contract is no exception.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Source of truth
|
|
284
|
+
|
|
285
|
+
- **Status**: draft
|
|
286
|
+
- **Date**: ${now}
|
|
287
|
+
- **Surfaces**: ${surfaceSummary}
|
|
288
|
+
- **Repository app/toolchain evidence**: ${evidence.appEvidencePaths.length > 0 ? evidence.appEvidencePaths.join(', ') : 'none detected'}
|
|
289
|
+
- **Evidence**: ${referenceNote}
|
|
290
|
+
- **Framework**: ${frameworkNote}
|
|
291
|
+
- **Tokens**: ${tokenNote}
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Brand
|
|
296
|
+
|
|
297
|
+
- **Personality**: <!-- Open question: what three adjectives describe this brand's character? -->
|
|
298
|
+
- **Trust signals**: <!-- Open question: what makes users trust this product? domain expertise, testimonials, price transparency, certifications? -->
|
|
299
|
+
- **Avoid**: <!-- Open question: what aesthetics, tones, or references does this brand explicitly reject? -->
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Product goals
|
|
304
|
+
|
|
305
|
+
${frameSummary}
|
|
306
|
+
|
|
307
|
+
<!-- Open question: what are the top two or three measurable outcomes this product must achieve? -->
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Personas & jobs
|
|
312
|
+
|
|
313
|
+
<!-- Open question: who uses this product and what job are they hiring it to do?
|
|
314
|
+
Format: Persona name — one sentence on who they are and what they need to accomplish. -->
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Information architecture
|
|
319
|
+
|
|
320
|
+
<!-- List the surfaces, their hierarchy, and the primary user path through them.
|
|
321
|
+
Example:
|
|
322
|
+
- / (home) — first impression, conversion
|
|
323
|
+
- /pricing — decision
|
|
324
|
+
- /docs — activation
|
|
325
|
+
Primary path: home → pricing → sign up
|
|
326
|
+
|
|
327
|
+
UX decision constraints (see theory/ux.md):
|
|
328
|
+
- Name the primary task the user arrives with on each surface.
|
|
329
|
+
- Name the most frequent action per surface (defaults are set for this case).
|
|
330
|
+
- Name the costliest error per surface and confirm a recovery path exists.
|
|
331
|
+
- Every state the user can reach must have an exit (no dead ends). -->
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Design principles
|
|
336
|
+
|
|
337
|
+
<!-- Two to four product-specific principles that resolve conflicts when two valid choices both work.
|
|
338
|
+
Example: "Clarity beats cleverness — when a UI pattern is clever but unfamiliar, use the familiar one." -->
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
## Visual language
|
|
343
|
+
|
|
344
|
+
### Color
|
|
345
|
+
<!-- Token names and roles. Fill from .omd/attribution.md if present. -->
|
|
346
|
+
|
|
347
|
+
### Typography
|
|
348
|
+
<!-- Type scale, font families, weight ladder. -->
|
|
349
|
+
|
|
350
|
+
### Spacing
|
|
351
|
+
<!-- Spacing ladder. -->
|
|
352
|
+
|
|
353
|
+
### Radius
|
|
354
|
+
<!-- Corner radius ladder. -->
|
|
355
|
+
|
|
356
|
+
### Motion
|
|
357
|
+
${evidence.hasMotionSpec
|
|
358
|
+
? '<!-- Refer to .omd/motion-spec.md for scene-level detail. Summarise the overall motion vocabulary here. -->'
|
|
359
|
+
: '<!-- Open question: duration range, easing vocabulary, and whether motion is decorative or functional. -->'}
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## Components
|
|
364
|
+
|
|
365
|
+
### Reused (already built)
|
|
366
|
+
<!-- List components already in the codebase with their location. -->
|
|
367
|
+
|
|
368
|
+
### New (to be built)
|
|
369
|
+
<!-- List components that do not yet exist and must be built. -->
|
|
370
|
+
|
|
371
|
+
### Variants
|
|
372
|
+
<!-- Which components need size/color/state variants? List them. -->
|
|
373
|
+
|
|
374
|
+
### States
|
|
375
|
+
<!-- Which components carry interaction states? Cross-reference Interaction states section below. -->
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## Accessibility
|
|
380
|
+
|
|
381
|
+
- **Target**: WCAG 2.2 AA minimum
|
|
382
|
+
- **Language**: <!-- primary language, e.g. "ko" for Korean. Determines word-break rules. -->
|
|
383
|
+
- **Focus management**: <!-- any non-standard focus routing (modals, skip-nav, single-page navigation)? -->
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Responsive behavior
|
|
388
|
+
|
|
389
|
+
- **Breakpoints**: 375px (base) / 768px / 1280px
|
|
390
|
+
- **Mobile-first**: yes — base styles target 375px, larger viewports layer on top
|
|
391
|
+
- **Exceptions**: <!-- any surfaces that are desktop-only or have non-standard breakpoints? -->
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## Interaction states
|
|
396
|
+
|
|
397
|
+
Every interactive surface must account for all applicable states. Each state is either
|
|
398
|
+
implemented or explicitly skipped with a reason recorded in \`omd decision\`.
|
|
399
|
+
|
|
400
|
+
### Loading
|
|
401
|
+
<!-- How does the UI signal that data is in flight? Skeleton, spinner, shimmer, or disabled state?
|
|
402
|
+
Which surfaces show loading: forms (after submit), data lists, async navigation? -->
|
|
403
|
+
|
|
404
|
+
### Empty
|
|
405
|
+
<!-- What does a data list, feed, or result set show when there is nothing to display?
|
|
406
|
+
An empty container is a design defect. Every list needs an empty state. -->
|
|
407
|
+
|
|
408
|
+
### Error
|
|
409
|
+
<!-- How are error conditions surfaced? Form validation errors (field-level + summary),
|
|
410
|
+
network failures, permission errors. Minimum: role=alert on error messages,
|
|
411
|
+
aria-invalid on failed fields, visible error copy that states what to do next. -->
|
|
412
|
+
|
|
413
|
+
### Success
|
|
414
|
+
<!-- How is a completed action confirmed? Inline confirmation, toast, page transition,
|
|
415
|
+
or a dedicated success screen? Duration and dismissal behaviour. -->
|
|
416
|
+
|
|
417
|
+
### Disabled
|
|
418
|
+
<!-- Disabled elements must still communicate why they are disabled. A greyed-out button
|
|
419
|
+
with no tooltip is an accessibility failure. Either remove the element when the
|
|
420
|
+
action is unavailable, or pair the disabled state with explanatory copy. -->
|
|
421
|
+
|
|
422
|
+
### Offline
|
|
423
|
+
<!-- Does this product have any offline capability or offline-detection UI?
|
|
424
|
+
If not, document that it is an explicit non-requirement. -->
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## Content voice
|
|
429
|
+
|
|
430
|
+
${evidence.hasVoiceStudy
|
|
431
|
+
? '<!-- Derived from .omd/voice-study.md. Summarise the register, vocabulary temperature, and sentence rhythm rules here. -->'
|
|
432
|
+
: '<!-- Open question: what register does this product use? Formal/informal, warm/authoritative, Korean 해요체/합니다체. Cite a voice study capture from the reference board. -->'}
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## Implementation constraints
|
|
437
|
+
|
|
438
|
+
- **Stack precedence**: explicit user request > existing repository stack/toolchain (including existing vanilla HTML) > React + Vite + TypeScript only for a truly blank greenfield
|
|
439
|
+
- **Build-time stack policy**: ${stackPolicy}
|
|
440
|
+
- **Dependencies**: ${dependencyPolicy}
|
|
441
|
+
- **Browser targets**: last 2 major versions of Chrome, Firefox, Safari, Edge
|
|
442
|
+
- **Performance**: <!-- any specific performance budget? LCP target, bundle size limit? -->
|
|
443
|
+
- **Frameworks/libraries already in use**: ${frameworkNote}
|
|
444
|
+
- **Additional constraints**: <!-- any deploy environment, CMS, or accessibility certification constraints? -->
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## Open questions
|
|
449
|
+
|
|
450
|
+
<!-- Collect unresolved decisions here. Each question should name who can answer it
|
|
451
|
+
and what the consequence of the default assumption is.
|
|
452
|
+
|
|
453
|
+
Template:
|
|
454
|
+
- Q: [question]
|
|
455
|
+
Default assumption: [what will be implemented if unanswered]
|
|
456
|
+
Consequence if wrong: [what breaks or must be rebuilt]
|
|
457
|
+
Owner: [designer / product / engineering]
|
|
458
|
+
-->
|
|
459
|
+
`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── Parser ────────────────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Parse the h2 section headings present in a design.md document.
|
|
466
|
+
* Returns a map from normalised heading text to the section body content.
|
|
467
|
+
*
|
|
468
|
+
* Normalisation: trimmed, lowercased. Matching against REQUIRED_SECTIONS
|
|
469
|
+
* is done case-insensitively at the call site.
|
|
470
|
+
*/
|
|
471
|
+
export function parseDesignSections(md: string): Map<string, string> {
|
|
472
|
+
const map = new Map<string, string>();
|
|
473
|
+
// Split on h2 headings; slice(1) drops the preamble before the first ##.
|
|
474
|
+
const chunks = md.split(/^##\s+/m).slice(1);
|
|
475
|
+
for (const chunk of chunks) {
|
|
476
|
+
const nl = chunk.indexOf('\n');
|
|
477
|
+
const heading = (nl === -1 ? chunk : chunk.slice(0, nl)).trim();
|
|
478
|
+
const body = nl === -1 ? '' : chunk.slice(nl + 1);
|
|
479
|
+
if (heading) map.set(heading.toLowerCase(), body);
|
|
480
|
+
}
|
|
481
|
+
return map;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Find the canonical REQUIRED_SECTIONS entry that matches a parsed heading,
|
|
486
|
+
* or null when no match is found.
|
|
487
|
+
*/
|
|
488
|
+
function matchSection(heading: string): DesignSection | null {
|
|
489
|
+
const lower = heading.toLowerCase();
|
|
490
|
+
for (const s of REQUIRED_SECTIONS) {
|
|
491
|
+
if (s.toLowerCase() === lower) return s;
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── Validator ─────────────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Validate a design.md document against the required section schema.
|
|
500
|
+
*
|
|
501
|
+
* Two violation codes:
|
|
502
|
+
*
|
|
503
|
+
* DESIGN-INCOMPLETE A required section is missing entirely from the document.
|
|
504
|
+
*
|
|
505
|
+
* DESIGN-INCOMPLETE The "Interaction states" section exists but does not
|
|
506
|
+
* enumerate any of the six required states (loading/empty/
|
|
507
|
+
* error/success/disabled/offline). A section that exists but
|
|
508
|
+
* leaves all states blank is worse than no section at all —
|
|
509
|
+
* it gives a false impression of coverage.
|
|
510
|
+
*
|
|
511
|
+
* Only called when `.omd/design.md` exists (the caller guards that). A bare project
|
|
512
|
+
* without design.md is not nagged.
|
|
513
|
+
*/
|
|
514
|
+
export function validateDesignMd(md: string): Violation[] {
|
|
515
|
+
const violations: Violation[] = [];
|
|
516
|
+
const sections = parseDesignSections(md);
|
|
517
|
+
|
|
518
|
+
// Collect which required sections are present via case-insensitive match.
|
|
519
|
+
const missing: DesignSection[] = [];
|
|
520
|
+
for (const required of REQUIRED_SECTIONS) {
|
|
521
|
+
const found = [...sections.keys()].some((k) => matchSection(k) === required);
|
|
522
|
+
if (!found) missing.push(required);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
for (const section of missing) {
|
|
526
|
+
violations.push({
|
|
527
|
+
id: 'DESIGN-INCOMPLETE',
|
|
528
|
+
severity: 'warn',
|
|
529
|
+
layer: 1,
|
|
530
|
+
category: 'system',
|
|
531
|
+
nodeId: 'page',
|
|
532
|
+
path: 'design',
|
|
533
|
+
value: section,
|
|
534
|
+
message: `design.md is missing the "${section}" section. Run \`omd design\` to refresh the contract, then fill in the open question.`,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Interaction states section exists but enumerates no states.
|
|
539
|
+
const interactionBody = [...sections.entries()]
|
|
540
|
+
.find(([k]) => matchSection(k) === 'Interaction states')?.[1] ?? null;
|
|
541
|
+
|
|
542
|
+
if (interactionBody !== null) {
|
|
543
|
+
const bodyLower = interactionBody.toLowerCase();
|
|
544
|
+
const enumerated = INTERACTION_STATES.filter((s) => bodyLower.includes(s));
|
|
545
|
+
if (enumerated.length === 0) {
|
|
546
|
+
violations.push({
|
|
547
|
+
id: 'DESIGN-INCOMPLETE',
|
|
548
|
+
severity: 'warn',
|
|
549
|
+
layer: 1,
|
|
550
|
+
category: 'system',
|
|
551
|
+
nodeId: 'page',
|
|
552
|
+
path: 'design',
|
|
553
|
+
value: 'Interaction states: (blank)',
|
|
554
|
+
message:
|
|
555
|
+
'design.md has an "Interaction states" section but it does not enumerate any states '
|
|
556
|
+
+ '(loading/empty/error/success/disabled/offline). A section that exists but leaves '
|
|
557
|
+
+ 'all states blank gives false coverage assurance. Fill in each state or skip it '
|
|
558
|
+
+ 'with a written reason.',
|
|
559
|
+
});
|
|
560
|
+
} else {
|
|
561
|
+
// Partial coverage: states present but some missing.
|
|
562
|
+
const missing2 = INTERACTION_STATES.filter((s) => !bodyLower.includes(s));
|
|
563
|
+
for (const state of missing2) {
|
|
564
|
+
violations.push({
|
|
565
|
+
id: 'DESIGN-INCOMPLETE',
|
|
566
|
+
severity: 'warn',
|
|
567
|
+
layer: 1,
|
|
568
|
+
category: 'system',
|
|
569
|
+
nodeId: 'page',
|
|
570
|
+
path: 'design',
|
|
571
|
+
value: `Interaction states: missing ${state}`,
|
|
572
|
+
message:
|
|
573
|
+
`design.md "Interaction states" section does not mention "${state}". `
|
|
574
|
+
+ 'Either implement this state and document it, or explicitly skip it with a reason in `omd decision`.',
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return violations;
|
|
581
|
+
}
|