@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,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.
|