@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,239 @@
1
+ # View transitions
2
+
3
+ The View Transitions API (`document.startViewTransition()`) enables animated transitions
4
+ between page states or between full pages — fading, sliding, morphing — with the browser
5
+ capturing before and after snapshots and interpolating between them. Named transitions
6
+ allow specific elements to be tracked: a card thumbnail that expands into a detail page,
7
+ a heading that persists across a route change.
8
+
9
+ ## When it earns its place / When it does not
10
+
11
+ Condition: a multi-page site or SPA where navigating between pages currently produces
12
+ an abrupt content swap. View transitions convert the abrupt swap into a continuous
13
+ spatial move — the user always understands where they came from and where they went.
14
+ Particularly effective for: image-detail expansions (thumbnail → full page), list-to-
15
+ detail navigations (article index → article), and route changes where a persistent
16
+ element (the site header, a product image) should appear to carry across.
17
+
18
+ Condition against: pages where navigation is rare and the content replacement is self-
19
+ explanatory — a settings page, a dashboard tab switch that is already instant. Also:
20
+ applications with complex, frequent state changes (a real-time data feed, a drag-and-
21
+ drop interface) where capturing before/after snapshots introduces visible latency. The
22
+ API captures a screenshot of the leaving state; on content-heavy pages this screenshot
23
+ capture is measurable and should be profiled before committing.
24
+
25
+ The technique is progressive enhancement: browsers without View Transitions API support
26
+ navigate normally without any animation. Never block navigation on this feature.
27
+
28
+ ## Parameters
29
+
30
+ ```css
31
+ /* View transition durations are set as ::view-transition-* pseudo-element rules,
32
+ not as custom properties on :root. But document the values here for the hand
33
+ to wire from the board's motion invariants. */
34
+
35
+ :root {
36
+ /* Duration for the default cross-fade (root view transition). */
37
+ --vt-duration: 280ms;
38
+
39
+ /* Duration for named element transitions (e.g., card → detail morph). */
40
+ --vt-named-duration: 380ms;
41
+
42
+ /* Easing for entering elements. */
43
+ --vt-enter-ease: var(--ease-out-expo);
44
+
45
+ /* Easing for exiting elements. */
46
+ --vt-exit-ease: var(--ease-in-quint);
47
+ }
48
+ ```
49
+
50
+ ## Implementation
51
+
52
+ ```css
53
+ /* ── Default cross-fade (applies to the entire page swap) ─────────────────── */
54
+
55
+ /* The browser's built-in view-transition cross-fade animates opacity from 1→0
56
+ (old) and 0→1 (new). Override duration and easing; the opacity animation is
57
+ provided by the UA stylesheet and runs as a compositor-only operation. */
58
+ ::view-transition-old(root) {
59
+ animation-duration: var(--vt-duration);
60
+ animation-timing-function: var(--vt-exit-ease);
61
+ /* Explicit declaration for clarity — matches the UA default: */
62
+ animation-name: -ua-view-transition-fade-out;
63
+ }
64
+
65
+ ::view-transition-new(root) {
66
+ animation-duration: var(--vt-duration);
67
+ animation-timing-function: var(--vt-enter-ease);
68
+ animation-name: -ua-view-transition-fade-in;
69
+ }
70
+
71
+ /* Custom fade-out / fade-in keyframes for full control over opacity. */
72
+ @keyframes vt-fade-out {
73
+ from { opacity: 1; }
74
+ to { opacity: 0; }
75
+ }
76
+
77
+ @keyframes vt-fade-in {
78
+ from { opacity: 0; }
79
+ to { opacity: 1; }
80
+ }
81
+
82
+ /* Apply custom keyframes instead of UA defaults when you need explicit control: */
83
+ /* ::view-transition-old(root) { animation-name: vt-fade-out; } */
84
+ /* ::view-transition-new(root) { animation-name: vt-fade-in; } */
85
+
86
+ /* ── Named transition: card thumbnail → detail hero ───────────────────────── */
87
+
88
+ /* On the list page: mark the card image as a named participant. */
89
+ .card-image[data-vt-id] {
90
+ view-transition-name: attr(data-vt-id); /* set dynamically per item */
91
+ }
92
+
93
+ /* On the detail page: the hero image carries the same view-transition-name. */
94
+ .detail-hero-image {
95
+ view-transition-name: card-detail-hero; /* set via JS to match the source */
96
+ }
97
+
98
+ /* Customise the named element's transition independently from the page fade. */
99
+ ::view-transition-old(card-detail-hero),
100
+ ::view-transition-new(card-detail-hero) {
101
+ animation-duration: var(--vt-named-duration);
102
+ animation-timing-function: var(--vt-enter-ease);
103
+ }
104
+
105
+ ::view-transition-group(card-detail-hero) {
106
+ /* The group animation is the morph between source and target positions/sizes.
107
+ Browser handles the interpolation; we set the duration and easing. */
108
+ animation-duration: var(--vt-named-duration);
109
+ animation-timing-function: var(--vt-enter-ease);
110
+ }
111
+ ```
112
+
113
+ ```js
114
+ // Multi-page (MPA) view transitions — triggered on link click.
115
+ // The `@view-transition { navigation: auto; }` CSS rule handles this in supporting
116
+ // browsers automatically. Add the rule and the browser does the rest.
117
+
118
+ // For programmatic control or SPA route changes:
119
+ async function navigateWithTransition(url) {
120
+ if (!document.startViewTransition) {
121
+ // Fallback: navigate normally.
122
+ window.location.href = url;
123
+ return;
124
+ }
125
+
126
+ // Set the named view-transition-name on the element that will morph.
127
+ // (Example: clicked card sets its image's view-transition-name before the transition.)
128
+ const transition = document.startViewTransition(async () => {
129
+ // Swap the DOM content (fetch new page, update router, etc.)
130
+ await fetch(url).then(/* ... update DOM ... */);
131
+ });
132
+
133
+ // Handle transition completion for cleanup.
134
+ await transition.finished;
135
+ }
136
+
137
+ // For MPA with CSS-only activation:
138
+ // Add this to your CSS and the browser handles it without JavaScript:
139
+ ```
140
+
141
+ ```css
142
+ /* MPA automatic view transitions (Chrome 126+, no JS required): */
143
+ @view-transition {
144
+ navigation: auto;
145
+ }
146
+ ```
147
+
148
+ ## React
149
+
150
+ ```tsx
151
+ // React Router 6.28+ has built-in View Transitions support via unstable_viewTransition.
152
+ // For earlier versions or custom implementations:
153
+
154
+ import { useNavigate } from 'react-router-dom';
155
+ import { useCallback } from 'react';
156
+
157
+ function useViewTransitionNavigate() {
158
+ const navigate = useNavigate();
159
+
160
+ return useCallback((to: string, options?: Parameters<typeof navigate>[1]) => {
161
+ if (!document.startViewTransition) {
162
+ navigate(to, options);
163
+ return;
164
+ }
165
+
166
+ document.startViewTransition(() => {
167
+ navigate(to, options);
168
+ });
169
+ }, [navigate]);
170
+ }
171
+
172
+ // Usage: assign view-transition-name on the element that should morph.
173
+ function CardItem({ id, image, title }: { id: string; image: string; title: string }) {
174
+ const navigate = useViewTransitionNavigate();
175
+
176
+ return (
177
+ <article
178
+ onClick={() => navigate(`/work/${id}`)}
179
+ style={{ cursor: 'pointer' }}
180
+ >
181
+ <img
182
+ src={image}
183
+ alt={title}
184
+ // The name links this element to its counterpart on the detail page.
185
+ style={{ viewTransitionName: `card-image-${id}` }}
186
+ />
187
+ </article>
188
+ );
189
+ }
190
+
191
+ // On the detail page:
192
+ function WorkDetail({ id }: { id: string }) {
193
+ return (
194
+ <img
195
+ src={`/images/${id}-hero.jpg`}
196
+ alt=""
197
+ style={{ viewTransitionName: `card-image-${id}` }}
198
+ />
199
+ );
200
+ }
201
+ ```
202
+
203
+ ## Reduced-motion variant
204
+
205
+ When `prefers-reduced-motion: reduce` is active, the view transition should use an
206
+ instant switch or a very short cross-fade — not the full animated morph.
207
+
208
+ ```css
209
+ @media (prefers-reduced-motion: reduce) {
210
+ ::view-transition-group(*),
211
+ ::view-transition-old(*),
212
+ ::view-transition-new(*) {
213
+ animation-duration: 0.01ms !important;
214
+ animation-delay: 0ms !important;
215
+ }
216
+ }
217
+ ```
218
+
219
+ This preserves the API's before/after snapshot mechanism (which prevents content flash
220
+ during SPA navigation) while removing the perceptible animation. Users get a fast,
221
+ clean swap without motion.
222
+
223
+ ## Performance note
224
+
225
+ `document.startViewTransition()` captures a screenshot of the current page state before
226
+ making changes. On content-heavy pages, this screenshot capture is measurable — profile
227
+ it before committing. The transition itself (the CSS animation between old and new
228
+ states) runs off the main thread for elements with composited properties.
229
+
230
+ Named transitions (`view-transition-name`) promote an element to its own compositor
231
+ layer during the transition. This is correct behaviour — the element needs to animate
232
+ independently from the page content around it. Remove or clear `view-transition-name`
233
+ on elements that are not currently transitioning; permanently promoted layers consume
234
+ GPU memory without benefit.
235
+
236
+ Unique `view-transition-name` values are required across all simultaneously visible
237
+ named elements. Two elements with the same `view-transition-name` produce undefined
238
+ behaviour. When setting names dynamically (e.g., per-card IDs), ensure the ID is stable
239
+ and unique across the page.
@@ -0,0 +1,186 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ type Expectation =
6
+ | { type: 'visible' | 'hidden'; selector: string }
7
+ | { type: 'text'; selector: string; value: string }
8
+ | { type: 'url'; value: string }
9
+ | { type: 'attribute'; selector: string; name: string; value: string };
10
+ type Step = { action: 'click' | 'fill' | 'press'; selector?: string; value?: string; key?: string; expect?: Expectation[] };
11
+ export interface ProbePlan {
12
+ name: string;
13
+ destructive: false;
14
+ expectedTabOrder?: string[];
15
+ steps: Step[];
16
+ }
17
+ export interface ProbeWarning { id: 'PROBE-TAB-DISORDER' | 'PROBE-DEAD-CONTROL'; severity: 'warn'; message: string }
18
+ export interface ProbeResult {
19
+ name: string;
20
+ target: string;
21
+ viewport: { width: number; height: number };
22
+ steps: Array<{
23
+ action: 'click' | 'fill' | 'press';
24
+ selector?: string;
25
+ key?: string;
26
+ ok: boolean;
27
+ expectations: Array<{ type: Expectation['type']; selector?: string; name?: string; value?: string; ok: boolean }>;
28
+ }>;
29
+ warnings: ProbeWarning[];
30
+ tabOrder?: string[];
31
+ }
32
+
33
+ const PRESS_KEYS = new Set([
34
+ 'Tab', 'Shift+Tab', 'Enter', 'Space', 'Escape',
35
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown',
36
+ ]);
37
+ const CREDENTIAL_SELECTOR = /password|token|secret|credential|auth/i;
38
+
39
+ function localUrl(target: string): string {
40
+ if (!/^https?:\/\//.test(target)) {
41
+ const path = resolve(target);
42
+ if (!existsSync(path)) throw new Error(`no such page: ${target}`);
43
+ return pathToFileURL(path).href;
44
+ }
45
+ const url = new URL(target);
46
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw new Error('probe refuses remote targets');
47
+ if (url.username || url.password) throw new Error('probe refuses authenticated targets');
48
+ return url.href;
49
+ }
50
+
51
+ export function validateProbePlan(input: unknown): ProbePlan {
52
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
53
+ throw new Error('probe plan requires name, destructive:false, and steps[]');
54
+ }
55
+ const raw = input as Record<string, unknown>;
56
+ if (typeof raw['name'] !== 'string' || !raw['name'].trim() || raw['destructive'] !== false || !Array.isArray(raw['steps'])) {
57
+ throw new Error('probe plan requires name, destructive:false, and steps[]');
58
+ }
59
+ const planKeys = new Set(['name', 'destructive', 'expectedTabOrder', 'steps']);
60
+ if (Object.keys(raw).some((key) => !planKeys.has(key))) {
61
+ throw new Error('probe refuses authenticated plans');
62
+ }
63
+ const tabOrder = raw['expectedTabOrder'];
64
+ if (tabOrder !== undefined && (!Array.isArray(tabOrder) || tabOrder.some((s) => typeof s !== 'string' || !s.trim()))) {
65
+ throw new Error('expectedTabOrder must contain selectors');
66
+ }
67
+ for (const value of raw['steps']) {
68
+ const step = value as Record<string, unknown>;
69
+ if (!step || typeof step !== 'object' || Object.keys(step).some((key) => !['action', 'selector', 'value', 'key', 'expect'].includes(key))) {
70
+ throw new Error('probe step contains unsupported or authenticated fields');
71
+ }
72
+ const action = step['action'];
73
+ if (typeof action !== 'string' || !['click', 'fill', 'press'].includes(action)) throw new Error(`unsafe probe action: ${String(action)}`);
74
+ const selector = step['selector'];
75
+ if (action !== 'press' && (typeof selector !== 'string' || !selector.trim())) throw new Error(`${action} requires selector`);
76
+ if (selector !== undefined && (typeof selector !== 'string' || !selector.trim())) throw new Error('probe selector must be non-empty');
77
+ if (action === 'click' && (step['value'] !== undefined || step['key'] !== undefined)) throw new Error('click contains unsupported fields');
78
+ if (action === 'fill') {
79
+ if (typeof step['value'] !== 'string' || !step['value'] || CREDENTIAL_SELECTOR.test(String(selector))) {
80
+ throw new Error('probe refuses credential or empty fills');
81
+ }
82
+ if (step['key'] !== undefined) throw new Error('fill contains unsupported fields');
83
+ }
84
+ if (action === 'press') {
85
+ if (typeof step['key'] !== 'string' || !PRESS_KEYS.has(step['key'])) throw new Error('press key is not in the safe navigation/activation allowlist');
86
+ if (step['value'] !== undefined) throw new Error('press contains unsupported fields');
87
+ }
88
+ const expects = step['expect'];
89
+ if (!Array.isArray(expects) || expects.length === 0) throw new Error('every probe action requires at least one declared expectation');
90
+ for (const value of expects) {
91
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('unsupported probe expectation');
92
+ const exp = value as Record<string, unknown>;
93
+ const type = exp['type'];
94
+ if (typeof type !== 'string' || !['visible', 'hidden', 'text', 'url', 'attribute'].includes(type)) {
95
+ throw new Error('unsupported probe expectation');
96
+ }
97
+ const allowed = type === 'url' ? ['type', 'value']
98
+ : type === 'attribute' ? ['type', 'selector', 'name', 'value']
99
+ : type === 'text' ? ['type', 'selector', 'value'] : ['type', 'selector'];
100
+ if (Object.keys(exp).some((key) => !allowed.includes(key))) throw new Error('invalid probe expectation fields');
101
+ if (type !== 'url' && (typeof exp['selector'] !== 'string' || !exp['selector'].trim())) throw new Error('expectation requires selector');
102
+ if (['text', 'url', 'attribute'].includes(type) && (typeof exp['value'] !== 'string' || (type !== 'attribute' && !exp['value']))) throw new Error('expectation requires value');
103
+ if (type === 'attribute' && (typeof exp['name'] !== 'string' || !exp['name'].trim())) throw new Error('attribute expectation requires name');
104
+ }
105
+ }
106
+ return input as ProbePlan;
107
+ }
108
+
109
+ export function readProbePlan(path: string): ProbePlan {
110
+ return validateProbePlan(JSON.parse(readFileSync(path, 'utf8')) as unknown);
111
+ }
112
+
113
+ async function checkExpectation(page: import('playwright').Page, exp: Expectation): Promise<boolean> {
114
+ if (exp.type === 'url') return page.url().includes(exp.value);
115
+ const el = page.locator(exp.selector).first();
116
+ if (exp.type === 'visible') return el.isVisible().catch(() => false);
117
+ if (exp.type === 'hidden') return el.isHidden().catch(() => false);
118
+ if (exp.type === 'text') return (await el.textContent().catch(() => null))?.includes(exp.value) ?? false;
119
+ if (exp.type === 'attribute') {
120
+ const actual = exp.name === 'value'
121
+ ? await el.inputValue().catch(() => null)
122
+ : await el.getAttribute(exp.name).catch(() => null);
123
+ return actual === exp.value;
124
+ }
125
+ return false;
126
+ }
127
+
128
+ export async function runProbe(target: string, plan: ProbePlan, viewport = { width: 390, height: 844 }): Promise<ProbeResult> {
129
+ const validated = validateProbePlan(plan);
130
+ const url = localUrl(target);
131
+ const { chromium } = await import('playwright');
132
+ // Always headless — OMD never opens a visible browser window in any situation.
133
+ const browser = await chromium.launch({ headless: true });
134
+ try {
135
+ const page = await browser.newPage({ viewport });
136
+ await page.goto(url, { waitUntil: 'networkidle' });
137
+ const result: ProbeResult = { name: validated.name, target: url, viewport: { ...viewport }, steps: [], warnings: [] };
138
+
139
+ if (validated.expectedTabOrder) {
140
+ const actual: string[] = [];
141
+ const matches: boolean[] = [];
142
+ await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
143
+ for (let i = 0; i < validated.expectedTabOrder.length; i += 1) {
144
+ await page.keyboard.press('Tab');
145
+ const observed = await page.evaluate((expected) => {
146
+ const el = document.activeElement as HTMLElement | null;
147
+ const label = el?.id ? `#${el.id}` : el?.getAttribute('data-testid') ? `[data-testid="${el.getAttribute('data-testid')}"]` : el?.tagName.toLowerCase() ?? '';
148
+ return { label, matches: el?.matches(expected) ?? false };
149
+ }, validated.expectedTabOrder[i]!);
150
+ actual.push(observed.label);
151
+ matches.push(observed.matches);
152
+ }
153
+ result.tabOrder = actual;
154
+ if (matches.some((match) => !match)) {
155
+ result.warnings.push({ id: 'PROBE-TAB-DISORDER', severity: 'warn', message: `expected ${validated.expectedTabOrder.join(' -> ')}, got ${actual.join(' -> ')}` });
156
+ }
157
+ }
158
+
159
+ for (const step of validated.steps) {
160
+ let actionOk = true;
161
+ try {
162
+ if (step.action === 'click') await page.locator(step.selector!).click();
163
+ else if (step.action === 'fill') await page.locator(step.selector!).fill(step.value!);
164
+ else if (step.selector) await page.locator(step.selector).press(step.key!);
165
+ else await page.keyboard.press(step.key!);
166
+ } catch { actionOk = false; }
167
+ const expectations: ProbeResult['steps'][number]['expectations'] = [];
168
+ for (const exp of step.expect ?? []) {
169
+ expectations.push({ ...exp, ok: await checkExpectation(page, exp) });
170
+ }
171
+ result.steps.push({ action: step.action, ...(step.selector ? { selector: step.selector } : {}), ...(step.key ? { key: step.key } : {}), ok: actionOk, expectations });
172
+ if (step.expect?.length && (!actionOk || expectations.some((expectation) => !expectation.ok))) {
173
+ result.warnings.push({ id: 'PROBE-DEAD-CONTROL', severity: 'warn', message: `${step.action} failed a declared expectation` });
174
+ }
175
+ }
176
+ return result;
177
+ } finally {
178
+ await browser.close();
179
+ }
180
+ }
181
+
182
+ export function writeProbeResult(path: string, result: ProbeResult): string {
183
+ mkdirSync(dirname(path), { recursive: true });
184
+ writeFileSync(path, `${JSON.stringify(result, null, 2)}\n`);
185
+ return path;
186
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "OMD safe probe plan",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": ["name", "destructive", "steps"],
7
+ "properties": {
8
+ "name": { "type": "string", "minLength": 1 },
9
+ "destructive": { "const": false },
10
+ "expectedTabOrder": {
11
+ "type": "array",
12
+ "items": { "type": "string", "minLength": 1 }
13
+ },
14
+ "steps": {
15
+ "type": "array",
16
+ "items": {
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "required": ["action"],
20
+ "properties": {
21
+ "action": { "enum": ["click", "fill", "press"] },
22
+ "selector": { "type": "string", "minLength": 1 },
23
+ "value": { "type": "string" },
24
+ "key": {
25
+ "enum": ["Tab", "Shift+Tab", "Enter", "Space", "Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"]
26
+ },
27
+ "expect": {
28
+ "type": "array",
29
+ "minItems": 1,
30
+ "items": {
31
+ "type": "object",
32
+ "additionalProperties": false,
33
+ "required": ["type"],
34
+ "properties": {
35
+ "type": { "enum": ["visible", "hidden", "text", "url", "attribute"] },
36
+ "selector": { "type": "string", "minLength": 1 },
37
+ "value": { "type": "string" },
38
+ "name": { "type": "string", "minLength": 1 }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,192 @@
1
+ # Composition contract protocol
2
+
3
+ `.omd/composition.md` is the durable bridge between research and isolated structural
4
+ sketches. `omd-composer` owns it. The artifact records composition decisions; it does not
5
+ copy a reference page or prescribe one visual answer.
6
+
7
+ The composer receives a sanitized frame/concept, clean copy deck, sanitized approved type
8
+ contract, and the scout's distilled transferable principles/invariants with source trust.
9
+ It does not receive or reproduce screenshots, assets, pixels, literal tokens, copied text,
10
+ full-page descriptions, or a source page's complete sequence. Read `theory/layout.md` and
11
+ `theory/ux.md` exactly before writing.
12
+
13
+ Use these H2 sections exactly and keep each non-empty. When the project has user-provided
14
+ references (`omd ref add --from-user`), the additional `## Reference synthesis` section is
15
+ required as well; `omd composition --check` enforces it.
16
+
17
+ Before writing any section, read the frame's `uxSurface` classification. It selects the
18
+ grammar (see `theory/ux.md` §Surface types): a `marketing` surface composes as a message
19
+ ladder; a `product` work surface composes as a task loop over screen regions; `editorial`
20
+ composes as a reading sequence; `mixed` names which screens follow which grammar. The
21
+ section names below stay the same across grammars — what changes is what a "section" is:
22
+ on a product surface it is a screen region or reachable state with a job in the task
23
+ loop, not a scroll chapter.
24
+
25
+ ## Input fingerprint
26
+
27
+ Record exactly one line for each input:
28
+
29
+ ```text
30
+ - Frame SHA-256: <64 lowercase hex>
31
+ - Copy deck SHA-256: <64 lowercase hex>
32
+ - Type proof SHA-256: <64 lowercase hex>
33
+ - Scout SHA-256: <64 lowercase hex>
34
+ ```
35
+
36
+ Hashes cover `.omd/frame.md`, `.omd/copy-deck.md`, `.omd/type-proof.md`, and
37
+ `.omd/scout.md`. When `.omd/scout.md` does not exist, the scout line is instead
38
+ `- Scout SHA-256: N/A — <specific reason>`. Never invent a scout hash. Any frame, copy,
39
+ type-proof, or scout-summary change invalidates the contract; rerun composer and the check.
40
+
41
+ ## Experience spine
42
+
43
+ For a `marketing` or `editorial` surface: for every section, record the entering user
44
+ question, exactly what new answer/evidence this section adds, its primary action, and why
45
+ the next section depends on it. This is a message ladder, not a list of fashionable
46
+ section names.
47
+
48
+ For a `product` surface: write the spine as the task loop — orient → locate → act →
49
+ feedback → next/recover. For every screen region (shell/navigation, work object, supporting
50
+ panels, state surfaces), record which loop step it serves, the frequent action it carries,
51
+ and why the region's position follows task order. Include the reachable states (loading,
52
+ empty first-run, filtered-to-zero, error/recovery, success) as spine entries with their
53
+ copy source; a work surface without designed states has an incomplete spine. Do not write
54
+ a persuasion ladder for a tool: a hero section above a queue is a grammar defect, not a
55
+ style choice.
56
+
57
+ ## Section dependency
58
+
59
+ Run the reorder/removal test. If adjacent sections can swap or disappear without weakening
60
+ meaning, revise their dependency or record the evidence-backed reason independence is useful.
61
+
62
+ ## Grid and alignment
63
+
64
+ Define the shared alignment/grid skeleton and the limited, intentional breaks that express
65
+ meaning. Do not choose a break because a reference used it.
66
+
67
+ ## Density and visual mass
68
+
69
+ Describe how density, visual mass, and negative space progress across the whole experience.
70
+ Relate every change to attention, evidence, action, or recovery.
71
+
72
+ ## Focal hierarchy
73
+
74
+ Define one dominant anchor in the first viewport and its visual-mass budget relative to the
75
+ value statement, proof, and visible primary CTA. On a `product` surface the dominant anchor
76
+ is the work object itself — the table, queue, canvas, form, or data view the task loop
77
+ operates on, at representative data density — and the "value statement" is at most one line
78
+ of orientation; a display-scale headline or decorative hero claiming the first viewport of
79
+ a work surface is a rejection condition, not an anchor. The CTA plus a predictable completion path
80
+ satisfies task reach; the terminal form or control surface does not have to appear in the
81
+ first viewport and earns no credit merely for being visible there. State a visible rejection
82
+ condition for a candidate whose anchor loses dominance, crowds the task cue, or becomes
83
+ detached from the concept.
84
+
85
+ The anchor may be lawful product/evidence media, an explanatory graphic, real interaction or
86
+ data, or concept-bearing typography. A photo is never mandatory. Do not invent product facts,
87
+ assets, or evidence to manufacture an anchor.
88
+
89
+ ## Domain form grammar
90
+
91
+ Derive recurring relational properties from the product's mechanism, material, or workflow.
92
+ Assign them to functional UI roles and state their limits. A metaphor that becomes decorative
93
+ shape instead of useful relationship fails this section.
94
+
95
+ ## Media roles
96
+
97
+ For each section, assign media one job: evidence, explanation, orientation, or action. When
98
+ the domain mechanism, material, or workflow is central, assign a lawful carrier for it or
99
+ name an explicit alternate non-media mental-model carrier and its limitation. `none because
100
+ no approved photo` is insufficient: a photo is not required, and absent assets do not excuse
101
+ an absent mental model. Do not invent product facts/assets or weaken the clean-room transfer
102
+ boundary.
103
+
104
+ ## Responsive recomposition
105
+
106
+ Use a desktop/mobile table. Preserve semantic DOM and focus order, the anchor's morphology
107
+ and relationship to value/proof/CTA, and an uninterrupted primary-task cue and completion
108
+ path while explaining how each relationship reframes. Do not write only `stack` or `hide`.
109
+ The full form need not occupy the first viewport. Account for reflow at 320 CSS px, while
110
+ production evidence still renders 1280x900 and 390x844.
111
+
112
+ ## Candidate axes
113
+
114
+ Define at least two genuinely different structural axes that can satisfy the same spine,
115
+ copy, type, and acceptance criteria. Axes vary spatial relationships, not content truth.
116
+
117
+ ## Transfer boundary
118
+
119
+ Permitted transfer: attributed relationships, measured invariants, and principles abstracted
120
+ from trusted evidence. Forbidden transfer: source identity in shipped UI, copy, assets,
121
+ literal tokens, full section order, pixels, recognizable silhouette, or unique interaction
122
+ and motion. An exact transplant is allowed only when the user explicitly requested that
123
+ specific transplant; record the request and attribution.
124
+
125
+ ## Reference synthesis
126
+
127
+ Required when user-origin references exist, and structurally validated whenever it is non-empty.
128
+ `omd composition --check` parses this section as a closed Markdown ABI: prose, headings,
129
+ fields, or axis rows outside the forms below are rejected.
130
+
131
+ An adopted transfer is one `### Feature: <specific feature>` record:
132
+
133
+ ```md
134
+ ### Feature: Inbox triage workspace
135
+ - Origin: explicit
136
+ - Assumption: N/A
137
+ - Primitive: Triage and inspect a queue item
138
+ - Source ref: ref-<first 16 lowercase hex of SHA-256(source + NUL + component)>
139
+ - Trust: Directly observed stable reference
140
+ - Uncertainty: Content changes may alter exact density.
141
+ - Structural rule: Queue and detail panels remain visible together.
142
+ - Adaptation: Fit that relationship to the current task flow.
143
+ - Token variation: Use destination tokens, not source values.
144
+ - Conflict resolution: Task, accessibility, mobile, then one destination system win.
145
+ - Destination route: /inbox
146
+ - Destination selector: [data-region="inbox"]
147
+ - Mobile behavior: Recompose into a focused drill-in flow.
148
+ #### Axes
149
+ - Information architecture/navigation | adapt | Queue navigation exposes current work. | Fit it to destination routes.
150
+ - Macro layout and panel/region geometry | adapt | Queue and detail remain paired. | Preserve the relationship at the work surface.
151
+ - Content density | N/A | N/A | The source has no usable density evidence.
152
+ - Typography/hierarchy | N/A | N/A | The source has no usable type evidence.
153
+ - Spacing/rhythm | N/A | N/A | The source has no usable rhythm evidence.
154
+ - Component anatomy | N/A | N/A | The source has no reusable anatomy evidence.
155
+ - Interaction/state/feedback | adapt | Selecting an item updates detail. | Use destination state vocabulary.
156
+ - Responsive/mobile recomposition | adapt | Detail becomes focused on narrow screens. | Preserve return to the queue.
157
+ - Motion/transition | N/A | N/A | The source has no transferable motion evidence.
158
+ ```
159
+
160
+ Use every axis exactly once: information architecture/navigation; macro layout and panel/region
161
+ geometry; content density; typography/hierarchy; spacing/rhythm; component anatomy;
162
+ interaction/state/feedback; responsive/mobile recomposition; motion/transition. Disposition is
163
+ exactly `accept`, `adapt`, `decline`, or `N/A`. Applicable axes require a substantive observed
164
+ rule and adaptation or decline rationale. `N/A` uses `N/A` as its observed rule and a substantive
165
+ reason. An adopted record must accept or adapt at least one structural or visual-system axis
166
+ (the first six); interaction-only or token-only transfer is invalid.
167
+
168
+ `Source ref` is the exact stable reference identity, not a hostname or prose mention. For a stored
169
+ user reference it is `ref-` plus the first 16 lowercase hexadecimal characters of
170
+ `SHA-256(source + NUL + component)`. This preserves two user references from the same host as
171
+ separate obligations. The checker compares normalized `Source ref` field values only; text
172
+ elsewhere in the section never satisfies reference coverage. A scout-origin reference instead
173
+ uses an uppercase hyphenated key matching `[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+`; hostnames and
174
+ free-form prose are invalid identities. Feature labels may be short when
175
+ specific; explanatory fields need substantive content. Destination routes must be non-placeholder
176
+ local routes beginning with `/` (including `/` itself). Destination selectors must be unique after
177
+ whitespace normalization and use only a stable `[data-*="…"]` selector or an accessible semantic
178
+ element/role selector.
179
+
180
+ A whole explicit feature may instead be declined without pretending it is an adopted transfer:
181
+
182
+ ```md
183
+ ### Decline: Document density
184
+ - Origin: explicit
185
+ - Source ref: ref-<first 16 lowercase hex of SHA-256(source + NUL + component)>
186
+ - Trust: User supplied reference
187
+ - Uncertainty: Scope is limited to the supplied page.
188
+ - Reason: Document density conflicts with rapid queue scanning.
189
+ ```
190
+
191
+ `omd composition --check` validates this ABI, section structure, fingerprint format, freshness,
192
+ and every user-reference mention. It does not score taste or aesthetics.