@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,310 @@
1
+ /**
2
+ * Responsive frame matching — F4 implementation.
3
+ *
4
+ * Groups frames that are viewport variants of the same screen using three
5
+ * heuristics applied in priority order:
6
+ *
7
+ * 1. Name-suffix matching (highest confidence)
8
+ * Frames that share a base name after stripping recognized device/width
9
+ * suffixes are grouped. Suffixes detected (case-insensitive, separated by
10
+ * space · / - _):
11
+ * English Desktop · Mobile · Tablet
12
+ * Korean 데스크톱 · 모바일 · 태블릿
13
+ * Numeric 3-4 digit widths (e.g. 375, 1440)
14
+ * Examples: "Home / Desktop" + "Home-Mobile" → screen "Home"
15
+ * "홈 1440" + "홈 375" → screen "홈"
16
+ * A frame whose full name matches a base name extracted from another frame
17
+ * (e.g. "Homepage" + "Homepage / Mobile") is also grouped here.
18
+ *
19
+ * 2. Structure-similarity within width bands (fallback)
20
+ * Frames that were not matched by name but fall in standard width bands and
21
+ * share near-identical node structure (Jaccard ≥ 0.75 on node-type counts)
22
+ * are paired across bands. Mobile (320–480 px) is the anchor; matching
23
+ * desktop (1200–1600 px) and optionally tablet (600–900 px) are added.
24
+ *
25
+ * 3. Unmatched leftovers
26
+ * Any frame that could not be placed into a set is returned in `unmatched`,
27
+ * labeled honestly rather than forced into a group.
28
+ *
29
+ * All functions are pure (no I/O), operating on FigmaSnapshot data produced by
30
+ * core/figma/client.ts.
31
+ */
32
+ import type { FigmaSnapshot, Band, FigmaResponsiveVariant, FigmaBreakpointSet } from './types.ts';
33
+ import type { SnapshotFrame } from './types.ts';
34
+
35
+ export type { Band, FigmaResponsiveVariant, FigmaBreakpointSet };
36
+
37
+ // ── Public result type ─────────────────────────────────────────────────────────
38
+
39
+ export interface ResponsiveResult {
40
+ breakpointSets: FigmaBreakpointSet[];
41
+ /** Frames that did not match any viewport-variant group. */
42
+ unmatched: FigmaResponsiveVariant[];
43
+ }
44
+
45
+ // ── Band classification ────────────────────────────────────────────────────────
46
+
47
+ /** Inclusive width ranges for the three standard bands. */
48
+ const BAND_RANGES: ReadonlyArray<readonly [Band, number, number]> = [
49
+ ['mobile', 320, 480],
50
+ ['tablet', 600, 900],
51
+ ['desktop', 1200, 1600],
52
+ ] as const;
53
+
54
+ /**
55
+ * Classify a pixel width into a named viewport band.
56
+ *
57
+ * Widths outside all three ranges (e.g. 0, 500, 1920) return 'unknown'.
58
+ * Pure and deterministic.
59
+ */
60
+ export function classifyBand(width: number): Band {
61
+ for (const [band, min, max] of BAND_RANGES) {
62
+ if (width >= min && width <= max) return band;
63
+ }
64
+ return 'unknown';
65
+ }
66
+
67
+ // ── Name-suffix stripping ──────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Device/width suffix pattern.
71
+ *
72
+ * Matches a separator (one or more of ` / - _`) followed by a recognized device
73
+ * keyword or a 3–4 digit number at the end of the string. Case-insensitive.
74
+ *
75
+ * Examples matched:
76
+ * " / Desktop" "- Mobile" "_Tablet" " 1440" " 375"
77
+ * " / 데스크톱" "- 모바일" " 태블릿"
78
+ */
79
+ const SUFFIX_RE = /[\s/\-_]+(?:desktop|mobile|tablet|데스크톱|모바일|태블릿|\d{3,4})\s*$/i;
80
+
81
+ /**
82
+ * Strip a recognized device/width suffix from a frame name and return the base.
83
+ *
84
+ * Returns the original name unchanged when no suffix is recognized.
85
+ * Pure and deterministic.
86
+ *
87
+ * Examples:
88
+ * "Home / Desktop" → "Home"
89
+ * "Home-Mobile" → "Home"
90
+ * "홈 1440" → "홈"
91
+ * "홈 375" → "홈"
92
+ * "Dashboard" → "Dashboard" (no suffix)
93
+ * "Mobile" → "Mobile" (no separator before the keyword)
94
+ */
95
+ export function extractBaseName(name: string): string {
96
+ return name.replace(SUFFIX_RE, '').trim();
97
+ }
98
+
99
+ // ── Frame width helpers ────────────────────────────────────────────────────────
100
+
101
+ /** Extract the pixel width of a frame from its root node's bounding box. */
102
+ function frameWidth(frame: SnapshotFrame): number {
103
+ return frame.nodes[0]?.absoluteBoundingBox?.width ?? 0;
104
+ }
105
+
106
+ // ── Structure fingerprint ──────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Build a compact structure fingerprint for a frame.
110
+ *
111
+ * The fingerprint is a stable string encoding the count of each node type
112
+ * present in the frame (all depths combined):
113
+ * "FRAME:4,RECTANGLE:2,TEXT:6" (sorted alphabetically for stability)
114
+ *
115
+ * Purpose: two frames implementing the same screen at different viewports will
116
+ * share roughly the same node-type distribution, even if element counts shift
117
+ * slightly due to layout reflow or conditional visibility.
118
+ */
119
+ function structureFingerprint(frame: SnapshotFrame): string {
120
+ const counts = new Map<string, number>();
121
+ for (const node of frame.nodes) {
122
+ counts.set(node.type, (counts.get(node.type) ?? 0) + 1);
123
+ }
124
+ return [...counts.entries()]
125
+ .sort((a, b) => a[0].localeCompare(b[0]))
126
+ .map(([t, n]) => `${t}:${n}`)
127
+ .join(',');
128
+ }
129
+
130
+ /**
131
+ * Compute a Jaccard-like similarity between two structure fingerprints.
132
+ *
133
+ * Treats each type:count entry as a multiset and returns:
134
+ * intersection / union where intersection = Σ min(ca, cb)
135
+ * union = Σ max(ca, cb)
136
+ *
137
+ * Returns 1.0 for identical fingerprints, 0.0 for completely disjoint ones.
138
+ */
139
+ export function fingerprintSimilarity(a: string, b: string): number {
140
+ if (a === b) return 1;
141
+ if (a === '' || b === '') return a === b ? 1 : 0;
142
+
143
+ const parse = (fp: string): Map<string, number> => {
144
+ const m = new Map<string, number>();
145
+ for (const part of fp.split(',')) {
146
+ const idx = part.lastIndexOf(':');
147
+ if (idx === -1) continue;
148
+ m.set(part.slice(0, idx), parseInt(part.slice(idx + 1), 10));
149
+ }
150
+ return m;
151
+ };
152
+
153
+ const ma = parse(a);
154
+ const mb = parse(b);
155
+
156
+ let intersection = 0;
157
+ let union = 0;
158
+ const allKeys = new Set([...ma.keys(), ...mb.keys()]);
159
+ for (const k of allKeys) {
160
+ const ca = ma.get(k) ?? 0;
161
+ const cb = mb.get(k) ?? 0;
162
+ intersection += Math.min(ca, cb);
163
+ union += Math.max(ca, cb);
164
+ }
165
+
166
+ return union === 0 ? 1 : intersection / union;
167
+ }
168
+
169
+ // ── Threshold ─────────────────────────────────────────────────────────────────
170
+
171
+ /**
172
+ * Minimum Jaccard similarity for the structure-similarity fallback to consider
173
+ * two frames variants of the same screen.
174
+ */
175
+ export const STRUCTURE_SIMILARITY_THRESHOLD = 0.75;
176
+
177
+ // ── Main entry point ──────────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Group frames in a snapshot into responsive breakpoint sets.
181
+ *
182
+ * See module-level doc-comment for full heuristic description.
183
+ * Pure — no I/O.
184
+ */
185
+ export function matchResponsiveFrames(snapshot: FigmaSnapshot): ResponsiveResult {
186
+ // Flatten all frames from all pages, annotated with width + band.
187
+ type AnnotatedFrame = { frame: SnapshotFrame; width: number; band: Band };
188
+ const all: AnnotatedFrame[] = snapshot.pages.flatMap((page) =>
189
+ page.frames.map((frame) => {
190
+ const width = frameWidth(frame);
191
+ return { frame, width, band: classifyBand(width) };
192
+ }),
193
+ );
194
+
195
+ const assigned = new Set<string>(); // frameIds that have been placed in a set
196
+ const breakpointSets: FigmaBreakpointSet[] = [];
197
+
198
+ // ── Heuristic 1: name-suffix matching ────────────────────────────────────────
199
+
200
+ // Phase A: frames that carry a recognized suffix → byBaseName[base].
201
+ const byBaseName = new Map<string, AnnotatedFrame[]>();
202
+ const knownBases = new Set<string>(); // base names produced by stripping a suffix
203
+
204
+ for (const f of all) {
205
+ const base = extractBaseName(f.frame.name);
206
+ if (base !== f.frame.name && base.length > 0) {
207
+ const arr = byBaseName.get(base) ?? [];
208
+ arr.push(f);
209
+ byBaseName.set(base, arr);
210
+ knownBases.add(base);
211
+ }
212
+ }
213
+
214
+ // Phase B: frames WITHOUT a suffix whose full name equals a known base
215
+ // e.g. "Homepage" pairs with "Homepage / Mobile".
216
+ for (const f of all) {
217
+ const base = extractBaseName(f.frame.name);
218
+ if (base === f.frame.name && knownBases.has(f.frame.name)) {
219
+ const arr = byBaseName.get(f.frame.name) ?? [];
220
+ arr.push(f);
221
+ byBaseName.set(f.frame.name, arr);
222
+ }
223
+ }
224
+
225
+ // Emit a breakpoint set for every base that has ≥ 2 frames.
226
+ for (const [screen, group] of byBaseName) {
227
+ if (group.length < 2) continue;
228
+ const variants: FigmaResponsiveVariant[] = group.map(({ frame, width, band }) => ({
229
+ frameId: frame.id,
230
+ name: frame.name,
231
+ width,
232
+ band,
233
+ }));
234
+ breakpointSets.push({ screen, variants });
235
+ for (const { frame } of group) assigned.add(frame.id);
236
+ }
237
+
238
+ // ── Heuristic 2: structure-similarity across width bands ──────────────────────
239
+
240
+ const unassigned = all.filter((f) => !assigned.has(f.frame.id));
241
+
242
+ const byBand: Record<Band, AnnotatedFrame[]> = {
243
+ mobile: [],
244
+ tablet: [],
245
+ desktop: [],
246
+ unknown: [],
247
+ };
248
+ for (const f of unassigned) {
249
+ byBand[f.band].push(f);
250
+ }
251
+
252
+ const bandAssigned = new Set<string>();
253
+
254
+ // Anchor on each mobile frame; find the best-matching desktop, then tablet.
255
+ for (const mobile of byBand.mobile) {
256
+ if (bandAssigned.has(mobile.frame.id)) continue;
257
+ const mFp = structureFingerprint(mobile.frame);
258
+
259
+ // Best-matching desktop
260
+ let bestDesktop: AnnotatedFrame | undefined;
261
+ let bestDesktopSim = STRUCTURE_SIMILARITY_THRESHOLD - Number.EPSILON;
262
+ for (const desktop of byBand.desktop) {
263
+ if (bandAssigned.has(desktop.frame.id)) continue;
264
+ const sim = fingerprintSimilarity(mFp, structureFingerprint(desktop.frame));
265
+ if (sim > bestDesktopSim) {
266
+ bestDesktopSim = sim;
267
+ bestDesktop = desktop;
268
+ }
269
+ }
270
+
271
+ if (bestDesktop === undefined) continue;
272
+
273
+ const variants: FigmaResponsiveVariant[] = [
274
+ { frameId: mobile.frame.id, name: mobile.frame.name, width: mobile.width, band: mobile.band },
275
+ { frameId: bestDesktop.frame.id, name: bestDesktop.frame.name, width: bestDesktop.width, band: bestDesktop.band },
276
+ ];
277
+ bandAssigned.add(mobile.frame.id);
278
+ bandAssigned.add(bestDesktop.frame.id);
279
+
280
+ // Optionally include the best tablet if it also clears the threshold.
281
+ let bestTablet: AnnotatedFrame | undefined;
282
+ let bestTabletSim = STRUCTURE_SIMILARITY_THRESHOLD - Number.EPSILON;
283
+ for (const tablet of byBand.tablet) {
284
+ if (bandAssigned.has(tablet.frame.id)) continue;
285
+ const sim = fingerprintSimilarity(mFp, structureFingerprint(tablet.frame));
286
+ if (sim > bestTabletSim) {
287
+ bestTabletSim = sim;
288
+ bestTablet = tablet;
289
+ }
290
+ }
291
+ if (bestTablet !== undefined) {
292
+ variants.push({ frameId: bestTablet.frame.id, name: bestTablet.frame.name, width: bestTablet.width, band: bestTablet.band });
293
+ bandAssigned.add(bestTablet.frame.id);
294
+ }
295
+
296
+ // Use the mobile frame's base name (suffix-stripped) as the screen identifier.
297
+ const screen = extractBaseName(mobile.frame.name) || mobile.frame.name;
298
+ breakpointSets.push({ screen, variants });
299
+ }
300
+
301
+ for (const id of bandAssigned) assigned.add(id);
302
+
303
+ // ── Unmatched ─────────────────────────────────────────────────────────────────
304
+
305
+ const unmatched: FigmaResponsiveVariant[] = all
306
+ .filter((f) => !assigned.has(f.frame.id))
307
+ .map(({ frame, width, band }) => ({ frameId: frame.id, name: frame.name, width, band }));
308
+
309
+ return { breakpointSets, unmatched };
310
+ }