@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,219 @@
1
+ # Sticky sidebar + scrolling content
2
+
3
+ > Candidate hypothesis only. Revalidate its condition, values, and responsive transition
4
+ > against the current composition contract; do not transfer this page recipe unchanged.
5
+
6
+ A sticky sidebar remains anchored in view while the main content column scrolls past it.
7
+ The sidebar holds content that gains from staying visible — navigation, a table of contents,
8
+ a reference panel, a summary of options while the user reads detail. The main column holds
9
+ the content that changes as the user scrolls. The spatial relationship between fixed and
10
+ moving creates a reading contract: one side is the reference, the other is the subject.
11
+
12
+ ## When it earns its place / When it does not
13
+
14
+ Condition: the sidebar content is actively useful while the main content is being read —
15
+ not just available, but genuinely better visible than hidden. A table of contents that
16
+ tracks the user's position, a filter panel that contextualises the results column, a
17
+ product summary that stays visible while the user reads the long-form description, a
18
+ step indicator during a multi-section form. The user benefits from the concurrent
19
+ visibility; removing the sticky behaviour would require scrolling back to reorient.
20
+
21
+ `core/theory/layout.md` on scan patterns: "primary navigation and key actions belong
22
+ on the left or in the top bar" — the F-pattern's left-edge scan aligns with a left
23
+ sidebar that provides structural context. A sticky sidebar on the right works only
24
+ when the content scan pattern is already bidirectional (documentation-style reference
25
+ pages where the user moves between code and explanation).
26
+
27
+ Condition against: mobile viewports where the sidebar consumes horizontal real estate
28
+ the main column cannot afford. Any sidebar whose content is not actively consulted while
29
+ the main column is being read — if the user reads the main column without ever glancing
30
+ at the sidebar, the sticky position is costing a column of space for no return. Short
31
+ pages where the sidebar reaches the end of the page before the scroll distance is
32
+ meaningful — `position: sticky` achieves nothing useful on a page under 200vh. And any
33
+ sidebar whose content duplicates rather than contextualises the main column.
34
+
35
+ ## Parameters
36
+
37
+ ```css
38
+ :root {
39
+ /* Sidebar width.
40
+ Fixed sidebar: typically 240–320px.
41
+ Proportional sidebar: 22–28% of the container — use when the content column
42
+ should reflow proportionally with viewport width. */
43
+ --sidebar-width: clamp(200px, 22%, 300px);
44
+
45
+ /* The top offset at which the sidebar sticks.
46
+ Match this to the height of any fixed top navigation bar.
47
+ If there is no fixed nav, a small offset (16–24px) prevents the sidebar
48
+ from jamming against the viewport top. */
49
+ --sidebar-sticky-top: var(--nav-height, 80px);
50
+
51
+ /* Gap between sidebar and main content column. */
52
+ --sidebar-gap: clamp(24px, 4vw, 48px);
53
+
54
+ /* The sidebar's maximum height before it becomes independently scrollable.
55
+ Set this to (100vh - var(--sidebar-sticky-top)) to allow the sidebar to
56
+ scroll internally if its content exceeds the viewport height. */
57
+ --sidebar-max-height: calc(100vh - var(--sidebar-sticky-top) - 2rem);
58
+ }
59
+ ```
60
+
61
+ ## Implementation
62
+
63
+ ```html
64
+ <!-- The wrapper defines the two-column layout.
65
+ The sidebar column holds the sticky element.
66
+ The main column scrolls normally. -->
67
+ <div class="sticky-layout">
68
+ <aside class="sticky-layout__sidebar" aria-label="Table of contents">
69
+ <nav class="toc">
70
+ <p class="toc__heading">Contents</p>
71
+ <ol class="toc__list">
72
+ <li><a href="#section-1" class="toc__link">Discovery</a></li>
73
+ <li><a href="#section-2" class="toc__link">Definition</a></li>
74
+ <li><a href="#section-3" class="toc__link">Design</a></li>
75
+ <li><a href="#section-4" class="toc__link">Delivery</a></li>
76
+ </ol>
77
+ </nav>
78
+ </aside>
79
+
80
+ <main class="sticky-layout__main">
81
+ <section id="section-1">
82
+ <h2>Discovery</h2>
83
+ <!-- Long-form content that scrolls -->
84
+ </section>
85
+
86
+ <section id="section-2">
87
+ <h2>Definition</h2>
88
+ <!-- Long-form content that scrolls -->
89
+ </section>
90
+ <!-- ... more sections ... -->
91
+ </main>
92
+ </div>
93
+ ```
94
+
95
+ ```css
96
+ /* ── Two-column grid ─────────────────────────────────────────────────────── */
97
+ .sticky-layout {
98
+ display: grid;
99
+ grid-template-columns: var(--sidebar-width) 1fr;
100
+ gap: var(--sidebar-gap);
101
+ padding-inline: var(--space-page-margin, 5vw);
102
+ align-items: start; /* critical: both columns align to the top; sticky works from there */
103
+ }
104
+
105
+ /* ── Sidebar: sticky within its column ───────────────────────────────────── */
106
+ .sticky-layout__sidebar {
107
+ position: sticky;
108
+ top: var(--sidebar-sticky-top);
109
+ max-height: var(--sidebar-max-height);
110
+ overflow-y: auto; /* if sidebar content exceeds viewport height, it scrolls internally */
111
+ /* Subtle scrollbar styling for the sidebar when it overflows */
112
+ scrollbar-width: thin;
113
+ scrollbar-color: var(--border-subtle) transparent;
114
+ }
115
+
116
+ /* ── Main content: normal block flow ─────────────────────────────────────── */
117
+ .sticky-layout__main {
118
+ min-width: 0; /* prevent grid blowout from long words or wide content */
119
+ }
120
+
121
+ .sticky-layout__main section {
122
+ padding-block: var(--space-section, 4rem);
123
+ /* A border or padding between sections helps the user perceive the scroll
124
+ narrative without the sidebar tracking — a visual chapter break. */
125
+ border-top: 1px solid var(--border-subtle, rgba(0,0,0,0.08));
126
+ }
127
+
128
+ .sticky-layout__main section:first-child {
129
+ border-top: none;
130
+ padding-top: 0;
131
+ }
132
+
133
+ /* ── Table of contents: simple, functional ─────────────────────────────── */
134
+ .toc__heading {
135
+ font-size: var(--text-xs, 0.75rem);
136
+ font-weight: var(--weight-medium, 500);
137
+ letter-spacing: 0.08em;
138
+ text-transform: uppercase;
139
+ color: var(--text-secondary);
140
+ margin-bottom: var(--space-3, 0.75rem);
141
+ }
142
+
143
+ .toc__list {
144
+ list-style: none;
145
+ padding: 0;
146
+ margin: 0;
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: var(--space-2, 0.5rem);
150
+ }
151
+
152
+ .toc__link {
153
+ font-size: var(--text-sm, 0.875rem);
154
+ color: var(--text-secondary);
155
+ text-decoration: none;
156
+ line-height: 1.4;
157
+ /* Active state for the current section — wired via IntersectionObserver in JS */
158
+ }
159
+
160
+ .toc__link[aria-current="true"],
161
+ .toc__link.is-active {
162
+ color: var(--color-accent);
163
+ font-weight: var(--weight-medium, 500);
164
+ }
165
+ ```
166
+
167
+ ## Responsive behavior
168
+
169
+ **375px (mobile):** The sidebar cannot coexist with the main column at this width. Move
170
+ sidebar content above the main content, or collapse it into a sticky summary bar at the
171
+ top. Do not simply hide the sidebar — if it provides navigation, it must remain accessible.
172
+
173
+ ```css
174
+ @media (max-width: 900px) {
175
+ .sticky-layout {
176
+ grid-template-columns: 1fr; /* single column */
177
+ }
178
+
179
+ .sticky-layout__sidebar {
180
+ position: static; /* remove sticky: stacks above main content */
181
+ max-height: none;
182
+ overflow-y: visible;
183
+ padding-bottom: var(--space-6, 1.5rem);
184
+ border-bottom: 1px solid var(--border-subtle);
185
+ margin-bottom: var(--space-6, 1.5rem);
186
+ }
187
+
188
+ /* Alternative: collapse to a sticky top bar on mobile.
189
+ Requires JavaScript to build a compact navigation from the sidebar content.
190
+ This pattern is documented in sticky-scene-transition.md. */
191
+ }
192
+ ```
193
+
194
+ **768px (tablet):** At 768px, `--sidebar-width: clamp(200px, 22%, 300px)` yields ~169px
195
+ — below the 200px floor, so the floor takes effect. A 200px sidebar at 768px leaves ~520px
196
+ for the main column, which is sufficient for body text at 50–65ch. Verify the sidebar
197
+ content does not overflow its column at this width.
198
+
199
+ **1280px (desktop):** The sidebar at `clamp(200px, 22%, 300px)` yields ~282px at 1280px.
200
+ The main column receives ~950px minus gap and page margin. At this width consider whether
201
+ the main column benefits from a `max-width: 72ch` constraint — a full-width main column
202
+ at 950px produces a 120ch+ measure, which is over the research-validated readable range.
203
+ `core/theory/layout.md` on form and text layout: 50–75ch is the validated range.
204
+
205
+ ## Do not combine with
206
+
207
+ **editorial-index-labels.md** in the same section — both create secondary reading tracks.
208
+ The index number (decorative, left-background) and the sidebar navigation (functional,
209
+ left-column) compete for the same spatial zone, producing ambiguity about what the left
210
+ side of the page means.
211
+
212
+ **asymmetric-diagonal-grid.md** — the diagonal layout breaks the column contract that
213
+ the sticky sidebar depends on. The sidebar's spatial promise (I stay while you scroll) is
214
+ undermined when the main column is itself off-axis or overlapping the sidebar zone.
215
+
216
+ **sticky-sidebar on pages under 200vh** — the sticky behaviour has no effect when the
217
+ page is too short for meaningful scroll distance. Use a static two-column layout instead
218
+ and reserve `position: sticky` for pages where the sidebar travels a meaningful distance
219
+ alongside the user.
@@ -0,0 +1,100 @@
1
+ # Typographic hero
2
+
3
+ > Candidate hypothesis only. Revalidate its condition, values, and responsive transition
4
+ > against the current composition contract; do not transfer this page recipe unchanged.
5
+
6
+ A typographic hero makes a short, concept-bearing line the primary visual event. It is an
7
+ exceptional composition, not a large-heading preset.
8
+ Use it with the register conditions in `core/theory/expressive.md`.
9
+
10
+ ## When it earns its place / When it does not
11
+
12
+ Use it when the actual headline is short enough to survive both target containers, the
13
+ chosen face and weight visibly carry the concept, and the product can keep supporting
14
+ context and the primary CTA usable. A justified huge Hangul case might use a compact
15
+ two-to-five-word declaration whose syllable-block rhythm is itself the identity: the proof
16
+ shows intentional wrapping, clean counters, loaded intended weights, and an immediate CTA
17
+ at 1280x900 and 390x844.
18
+
19
+ Do not use it when the headline is long, generic, or doing the work of several paragraphs.
20
+ A giant heavy Korean sentence that wraps into a dense wall, clips at mobile, falls back to
21
+ another face, or pushes all secondary hierarchy and action away has failed even if it looks
22
+ dramatic in one screenshot.
23
+
24
+ ## Parameters
25
+
26
+ The passing actual-copy specimen must supply these required custom properties:
27
+
28
+ ```text
29
+ --type-proof-hero-measure
30
+ --type-proof-hero-size
31
+ --type-proof-hero-weight
32
+ --type-proof-hero-leading
33
+ --type-proof-hero-tracking
34
+ --type-proof-hero-gap
35
+ --type-proof-hero-padding-block
36
+ ```
37
+
38
+ There are no recipe defaults. `.omd/type-proof.md` records the values produced by the
39
+ specimen for the selected copy and container. CSS `clamp()`, container queries, media
40
+ queries, or discrete type steps are all valid when the two rendered targets justify them.
41
+ Letter spacing follows visible glyph anatomy; there is no universal negative limit.
42
+
43
+ ## Implementation
44
+
45
+ Use semantic heading order and let content determine block height. Keep overflow visible
46
+ unless a separate, evidenced visual effect owns the crop; never use clipping to hide failed
47
+ wrapping.
48
+
49
+ ```css
50
+ .hero {
51
+ display: grid;
52
+ align-content: center;
53
+ gap: var(--type-proof-hero-gap);
54
+ padding-block: var(--type-proof-hero-padding-block);
55
+ }
56
+
57
+ .hero__title {
58
+ max-inline-size: var(--type-proof-hero-measure);
59
+ margin: 0;
60
+ font-size: var(--type-proof-hero-size);
61
+ font-weight: var(--type-proof-hero-weight);
62
+ line-height: var(--type-proof-hero-leading);
63
+ letter-spacing: var(--type-proof-hero-tracking);
64
+ text-wrap: balance;
65
+ overflow: visible;
66
+ }
67
+ ```
68
+
69
+ The secondary line and primary action remain in the same reading sequence. If display type
70
+ makes either unavailable without scrolling in the target task, reduce or restructure it.
71
+
72
+ ## Proof requirements
73
+
74
+ Before structure, render the real headline, secondary copy, CTA, mixed Korean/Latin/numerals,
75
+ and fallback state at 1280x900 and 390x844. Record requested and computed family/weight,
76
+ FontFace loading status, line count, wrap points, clipping, orphan behavior, and rejected
77
+ alternatives in `.omd/type-proof.md`.
78
+
79
+ After selecting the production structure, repeat the proof in the real hero container.
80
+ Copy, face/file, weight/axis, or container-width changes invalidate the earlier proof.
81
+ Browser evidence does not identify the physical font used for each glyph.
82
+
83
+ ## Responsive behavior
84
+
85
+ The required evidence is the actual-copy specimen at 390x844 and 1280x900, not a fixed
86
+ scaling formula. At each viewport record the selected container, line count, wrap points,
87
+ clipping, fallback behavior, and the location of secondary copy and CTA. Tablet behavior
88
+ may interpolate or use a discrete step, but it must preserve the same reading order.
89
+
90
+ ## Do not combine with
91
+
92
+ Avoid another dominant text event in the same opening view, such as an automatic marquee or
93
+ equally forceful split-text entrance. One technique owns the memorable moment. Motion, if
94
+ used, follows the motion specification and preserves the proof-clean final state.
95
+
96
+ ## Linter notes
97
+
98
+ There is no safe huge-type threshold. Moderate type can be expressive; very large type can
99
+ be correct. Deterministic checks may catch clipping or accessibility defects, while the
100
+ typography proof judges concept, glyphs, loading, wrapping, and hierarchy.
@@ -0,0 +1,233 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { loadRefs } from '../ref/store.ts';
5
+
6
+ export const COMPOSITION_SECTIONS = [
7
+ 'Input fingerprint', 'Experience spine', 'Section dependency', 'Grid and alignment',
8
+ 'Density and visual mass', 'Focal hierarchy', 'Domain form grammar', 'Media roles',
9
+ 'Responsive recomposition', 'Candidate axes', 'Transfer boundary',
10
+ ] as const;
11
+
12
+ // Allowed-but-optional H2 headings whose row/field schema is owned by another canonical
13
+ // validator (task evidence owns `## UX task coverage`). The composition contract only
14
+ // recognizes the heading so it can be authored as normal LF Markdown; it never restates
15
+ // or revalidates that section's internal schema.
16
+ export const AUXILIARY_SECTIONS = ['UX task coverage'] as const;
17
+
18
+ export interface CompositionContractFinding {
19
+ id: 'COMPOSITION-MISSING' | 'COMPOSITION-SECTION' | 'COMPOSITION-HASH' | 'COMPOSITION-STALE' | 'COMPOSITION-SCOUT' | 'COMPOSITION-SYNTHESIS';
20
+ path: string;
21
+ message: string;
22
+ }
23
+
24
+ export const SYNTHESIS_SECTION = 'Reference synthesis';
25
+ export const SYNTHESIS_AXES = [
26
+ 'Information architecture/navigation', 'Macro layout and panel/region geometry', 'Content density',
27
+ 'Typography/hierarchy', 'Spacing/rhythm', 'Component anatomy', 'Interaction/state/feedback',
28
+ 'Responsive/mobile recomposition', 'Motion/transition',
29
+ ] as const;
30
+
31
+ const structuralAxes = new Set(SYNTHESIS_AXES.slice(0, 6));
32
+ const synthesisPath = `.omd/composition.md#${SYNTHESIS_SECTION}`;
33
+ const synthesisFinding = (message: string): CompositionContractFinding => ({ id: 'COMPOSITION-SYNTHESIS', path: synthesisPath, message });
34
+ const sectionFinding = (section: string, message: string): CompositionContractFinding => ({ id: 'COMPOSITION-SECTION', path: `.omd/composition.md#${section}`, message });
35
+ const normalize = (value: string): string => value.trim().replace(/\s+/g, ' ').toLowerCase();
36
+ const isPlaceholder = (value: string): boolean => /^(?:n\/?a|none|unknown|unspecified|tbd|todo|placeholder|example)(?:\s|$)/i.test(value.trim());
37
+ const substantive = (value: string): boolean => value.trim().length >= 8 && !isPlaceholder(value);
38
+ const recordName = (kind: string, feature: string): string => `${kind} "${feature}"`;
39
+
40
+ interface ParsedSections { sections: Map<string, string[]>; findings: CompositionContractFinding[]; }
41
+ function parseSections(markdown: string): ParsedSections {
42
+ const sections = new Map<string, string[]>();
43
+ const findings: CompositionContractFinding[] = [];
44
+ const separator = /[\u2028\u2029\u0085\u000B\u000C]/.exec(markdown);
45
+ if (separator) findings.push(sectionFinding('document', `composition must not contain a Unicode line or paragraph separator (found U+${separator[0]!.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}); author headings with normal LF line breaks`));
46
+ let current: string | undefined;
47
+ for (const line of markdown.split(/\r?\n/)) {
48
+ const heading = /^##\s+(.+?)\s*$/.exec(line);
49
+ if (heading) {
50
+ const name = heading[1]!;
51
+ if (![...COMPOSITION_SECTIONS, SYNTHESIS_SECTION, ...AUXILIARY_SECTIONS].includes(name as never)) findings.push(sectionFinding(name, `unknown H2 section: ${name}`));
52
+ else if (sections.has(name)) findings.push(sectionFinding(name, `duplicate H2 section: ${name}`));
53
+ else sections.set(name, []);
54
+ current = name;
55
+ } else if (current === undefined) {
56
+ if (line.trim() !== '') findings.push(sectionFinding('document', 'content must appear inside an allowed H2 section'));
57
+ } else {
58
+ sections.get(current)?.push(line);
59
+ }
60
+ }
61
+ return { sections, findings };
62
+ }
63
+
64
+ function validateFingerprint(lines: string[], inputs: CompositionContractInputs): CompositionContractFinding[] {
65
+ const findings: CompositionContractFinding[] = [];
66
+ const values = new Map<string, string>();
67
+ for (const line of lines) {
68
+ if (line.trim() === '') continue;
69
+ const match = /^- (Frame SHA-256|Copy deck SHA-256|Type proof SHA-256|Scout SHA-256): (.+)$/.exec(line);
70
+ if (!match) { findings.push({ id: 'COMPOSITION-HASH', path: '.omd/composition.md#Input fingerprint', message: 'Input fingerprint contains an unknown or malformed line' }); continue; }
71
+ const key = match[1]!;
72
+ if (values.has(key)) findings.push({ id: 'COMPOSITION-HASH', path: '.omd/composition.md#Input fingerprint', message: `${key} must appear exactly once` });
73
+ else values.set(key, match[2]!);
74
+ }
75
+ const required: Array<[string, string, string | undefined]> = [
76
+ ['Frame', 'frame.md', inputs.frame], ['Copy deck', 'copy-deck.md', inputs.copyDeck], ['Type proof', 'type-proof.md', inputs.typeProof],
77
+ ];
78
+ for (const [label, filename, actual] of required) {
79
+ const value = values.get(`${label} SHA-256`);
80
+ if (!value || !/^[0-9a-f]{64}$/.test(value)) findings.push({ id: 'COMPOSITION-HASH', path: '.omd/composition.md#Input fingerprint', message: `${label} SHA-256 must be exactly 64 lowercase hex characters` });
81
+ else if (actual === undefined) findings.push({ id: 'COMPOSITION-STALE', path: `.omd/${filename}`, message: `${filename} is missing; composition cannot be current` });
82
+ else if (value !== actual) findings.push({ id: 'COMPOSITION-STALE', path: `.omd/${filename}`, message: `${filename} changed after composition was written` });
83
+ }
84
+ const scout = values.get('Scout SHA-256');
85
+ if (inputs.scout !== undefined) {
86
+ if (!scout || !/^[0-9a-f]{64}$/.test(scout)) findings.push({ id: 'COMPOSITION-SCOUT', path: '.omd/composition.md#Input fingerprint', message: 'Scout SHA-256 must be a 64-hex hash while .omd/scout.md exists' });
87
+ else if (scout !== inputs.scout) findings.push({ id: 'COMPOSITION-STALE', path: '.omd/scout.md', message: 'scout.md changed after composition was written' });
88
+ } else if (!scout || !/^N\/A — \S.*$/.test(scout)) {
89
+ findings.push({ id: 'COMPOSITION-SCOUT', path: '.omd/composition.md#Input fingerprint', message: 'absent scout.md requires `N/A — <reason>` instead of a hash or empty value' });
90
+ }
91
+ return findings;
92
+ }
93
+
94
+ const featureFields = ['Origin', 'Assumption', 'Primitive', 'Source ref', 'Trust', 'Uncertainty', 'Structural rule', 'Adaptation', 'Token variation', 'Conflict resolution', 'Destination route', 'Destination selector', 'Mobile behavior'] as const;
95
+ const declineFields = ['Origin', 'Source ref', 'Trust', 'Uncertainty', 'Reason'] as const;
96
+ interface SynthesisResult { findings: CompositionContractFinding[]; sourceRefs: string[]; }
97
+
98
+ function validRoute(value: string): boolean {
99
+ if (value === '/') return true;
100
+ if (!/^\/[A-Za-z0-9._~!$&'()*+,;=:@\/-]*$/.test(value) || value.startsWith('//')) return false;
101
+ const segments = value.slice(1).split('/');
102
+ return segments.every((segment) => segment !== '.' && segment !== '..') && !isPlaceholder(segments[0] ?? '');
103
+ }
104
+ function normalizedSelector(value: string): string | undefined {
105
+ const selector = value.trim().replace(/\s+/g, ' ');
106
+ if (/^\[data-[a-z][a-z0-9-]*=(?:"[^"]+"|'[^']+')\]$/.test(selector)) return selector;
107
+ if (/^(?:main|nav|header|footer|aside|form|table|dialog|button|input|select|textarea)(?:\[aria-(?:label|labelledby)=(?:"[^"]+"|'[^']+')\])?$/.test(selector)) return selector;
108
+ if (/^\[role=(?:"(?:main|navigation|dialog|search|form|region)"|'(?:main|navigation|dialog|search|form|region)')\]$/.test(selector)) return selector;
109
+ return undefined;
110
+ }
111
+
112
+ function validateReferenceSynthesis(lines: string[]): SynthesisResult {
113
+ const findings: CompositionContractFinding[] = [];
114
+ const sourceRefs: string[] = [];
115
+ const records: Array<{ kind: 'Feature' | 'Decline'; feature: string; lines: string[] }> = [];
116
+ let current: { kind: 'Feature' | 'Decline'; feature: string; lines: string[] } | undefined;
117
+ for (const line of lines) {
118
+ if (line.trim() === '') continue;
119
+ const heading = /^### (Feature|Decline):\s*(.*?)\s*$/.exec(line);
120
+ if (heading) { current = { kind: heading[1]! as 'Feature' | 'Decline', feature: heading[2]!, lines: [] }; records.push(current); }
121
+ else if (!current) findings.push(synthesisFinding('Reference synthesis may contain only Feature or Decline records'));
122
+ else current.lines.push(line);
123
+ }
124
+ if (records.length === 0) findings.push(synthesisFinding('non-empty Reference synthesis must use ### Feature: or ### Decline: records'));
125
+ const selectors = new Set<string>();
126
+ for (const record of records) {
127
+ const identity = recordName(record.kind, record.feature);
128
+ if (!record.feature.trim() || isPlaceholder(record.feature)) findings.push(synthesisFinding(`${identity}: feature label is missing or a placeholder`));
129
+ const fields = new Map<string, string>();
130
+ const axes: string[][] = [];
131
+ const expected = record.kind === 'Feature' ? featureFields : declineFields;
132
+ let nextField = 0;
133
+ let axisMode = false;
134
+ for (const line of record.lines) {
135
+ if (line === '#### Axes' && record.kind === 'Feature' && !axisMode) {
136
+ if (nextField !== expected.length) findings.push(synthesisFinding(`${identity}: Axes block is misplaced before required fields`));
137
+ axisMode = true;
138
+ continue;
139
+ }
140
+ if (axisMode) {
141
+ const columns = line.startsWith('- ') ? line.slice(2).split(' | ') : [];
142
+ if (columns.length !== 4) findings.push(synthesisFinding(`${identity}: malformed or stray content in Axes block`));
143
+ else axes.push(columns);
144
+ continue;
145
+ }
146
+ const field = /^- ([A-Za-z][A-Za-z ]*): (.*)$/.exec(line);
147
+ if (!field) { findings.push(synthesisFinding(`${identity}: malformed or stray record content`)); continue; }
148
+ const name = field[1]!;
149
+ if (!expected.includes(name as never)) { findings.push(synthesisFinding(`${identity}: unknown or misplaced field "${name}"`)); continue; }
150
+ if (fields.has(name)) findings.push(synthesisFinding(`${identity}: ${name} must appear exactly once`));
151
+ else {
152
+ if (expected[nextField] !== name) findings.push(synthesisFinding(`${identity}: field "${name}" is misplaced`));
153
+ else nextField++;
154
+ fields.set(name, field[2]!);
155
+ }
156
+ }
157
+ for (const name of expected) if (!fields.has(name) || !fields.get(name)!.trim()) findings.push(synthesisFinding(`${identity}: ${name} must appear exactly once with a non-empty value`));
158
+ const origin = fields.get('Origin');
159
+ const source = fields.get('Source ref');
160
+ if (source && !/^(?:ref-[0-9a-f]{16}|[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)$/.test(source)) findings.push(synthesisFinding(`${identity}: Source ref must be a stable ref identity or scout key`));
161
+ else if (source) sourceRefs.push(normalize(source));
162
+ for (const name of ['Trust', 'Uncertainty'] as const) if (fields.has(name) && !substantive(fields.get(name)!)) findings.push(synthesisFinding(`${identity}: ${name} must be substantive`));
163
+ if (record.kind === 'Decline') {
164
+ if (origin !== 'explicit') findings.push(synthesisFinding(`${identity}: a Decline record must use Origin: explicit`));
165
+ if (fields.has('Reason') && !substantive(fields.get('Reason')!)) findings.push(synthesisFinding(`${identity}: Decline Reason must be substantive`));
166
+ continue;
167
+ }
168
+ if (origin !== 'explicit' && origin !== 'inferred') findings.push(synthesisFinding(`${identity}: Origin must be exactly explicit or inferred`));
169
+ const assumption = fields.get('Assumption');
170
+ if (origin === 'explicit' && assumption !== 'N/A') findings.push(synthesisFinding(`${identity}: explicit Origin requires Assumption: N/A`));
171
+ if (origin === 'inferred' && (!assumption || !substantive(assumption))) findings.push(synthesisFinding(`${identity}: inferred Origin requires a substantive Assumption`));
172
+ for (const name of ['Primitive', 'Structural rule', 'Adaptation', 'Token variation', 'Conflict resolution', 'Mobile behavior'] as const) if (fields.has(name) && !substantive(fields.get(name)!)) findings.push(synthesisFinding(`${identity}: ${name} must be substantive`));
173
+ const route = fields.get('Destination route');
174
+ if (route && !validRoute(route)) findings.push(synthesisFinding(`${identity}: Destination route must be a non-placeholder local route beginning with /`));
175
+ const selector = fields.get('Destination selector');
176
+ if (selector) {
177
+ const normalized = normalizedSelector(selector);
178
+ if (!normalized) findings.push(synthesisFinding(`${identity}: Destination selector must be a stable data-* or accessible semantic selector`));
179
+ else if (selectors.has(normalized)) findings.push(synthesisFinding(`${identity}: Destination selector "${normalized}" is duplicated`));
180
+ else selectors.add(normalized);
181
+ }
182
+ if (!axisMode) findings.push(synthesisFinding(`${identity}: every adopted record must contain a #### Axes block`));
183
+ const seen = new Set<string>();
184
+ for (const [axis = '', disposition = '', observed = '', adaptation = ''] of axes) {
185
+ if (!SYNTHESIS_AXES.includes(axis as typeof SYNTHESIS_AXES[number])) { findings.push(synthesisFinding(`${identity}: unknown axis "${axis}"`)); continue; }
186
+ if (seen.has(axis)) findings.push(synthesisFinding(`${identity}: axis "${axis}" must appear exactly once`));
187
+ seen.add(axis);
188
+ if (!['accept', 'adapt', 'decline', 'N/A'].includes(disposition)) findings.push(synthesisFinding(`${identity}: axis "${axis}" has invalid disposition`));
189
+ else if (disposition === 'N/A' ? observed !== 'N/A' || !substantive(adaptation) : !substantive(observed) || !substantive(adaptation)) findings.push(synthesisFinding(`${identity}: axis "${axis}" requires a substantive ${disposition === 'N/A' ? 'N/A reason' : 'observed rule and adaptation or decline rationale'}`));
190
+ }
191
+ for (const axis of SYNTHESIS_AXES) if (!seen.has(axis)) findings.push(synthesisFinding(`${identity}: axis "${axis}" must appear exactly once`));
192
+ if (!axes.some(([axis, disposition]) => structuralAxes.has(axis as typeof SYNTHESIS_AXES[number]) && (disposition === 'accept' || disposition === 'adapt'))) findings.push(synthesisFinding(`${identity}: adopted records cannot be interaction-only or token-only`));
193
+ }
194
+ return { findings, sourceRefs };
195
+ }
196
+
197
+ function sha256(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); }
198
+
199
+ export interface CompositionContractInputs {
200
+ contract?: string; frame?: string; copyDeck?: string; typeProof?: string; scout?: string;
201
+ /** Exact normalized Source ref identities for user-origin refs, not host labels. */
202
+ userRefLabels?: string[];
203
+ }
204
+
205
+ export function validateCompositionContractSource(inputs: CompositionContractInputs): CompositionContractFinding[] {
206
+ if (inputs.contract === undefined) return [{ id: 'COMPOSITION-MISSING', path: '.omd/composition.md', message: 'composition contract is missing' }];
207
+ const parsed = parseSections(inputs.contract);
208
+ const findings = [...parsed.findings];
209
+ for (const section of COMPOSITION_SECTIONS) if (!parsed.sections.get(section)?.join('\n').trim()) findings.push(sectionFinding(section, `required section is missing or empty: ${section}`));
210
+ findings.push(...validateFingerprint(parsed.sections.get('Input fingerprint') ?? [], inputs));
211
+ const synthesisLines = parsed.sections.get(SYNTHESIS_SECTION);
212
+ const synthesis = synthesisLines?.join('\n').trim() ?? '';
213
+ const result = synthesis ? validateReferenceSynthesis(synthesisLines!) : { findings: [], sourceRefs: [] };
214
+ findings.push(...result.findings);
215
+ const userRefs = inputs.userRefLabels ?? [];
216
+ if (userRefs.length > 0 && !synthesis) findings.push(synthesisFinding(`user-provided references exist but the ${SYNTHESIS_SECTION} section is missing or empty`));
217
+ else for (const ref of userRefs) if (!result.sourceRefs.includes(normalize(ref))) findings.push(synthesisFinding(`user reference "${ref}" is not mapped by an exact Source ref field in ${SYNTHESIS_SECTION}`));
218
+ return findings.sort((a, b) => a.path.localeCompare(b.path) || a.id.localeCompare(b.id) || a.message.localeCompare(b.message));
219
+ }
220
+
221
+ /** Stable authoring identity: deterministic, safe, and distinct for same-host references. */
222
+ function refIdentity(source: string, component: string): string {
223
+ return `ref-${createHash('sha256').update(`${source}\0${component}`).digest('hex').slice(0, 16)}`;
224
+ }
225
+
226
+ export function validateCompositionContract(root: string): CompositionContractFinding[] {
227
+ const omd = join(root, '.omd');
228
+ const readHash = (filename: string): string | undefined => { const path = join(omd, filename); return existsSync(path) ? sha256(path) : undefined; };
229
+ const contractPath = join(omd, 'composition.md');
230
+ const frame = readHash('frame.md'); const copyDeck = readHash('copy-deck.md'); const typeProof = readHash('type-proof.md'); const scout = readHash('scout.md');
231
+ const userRefLabels = loadRefs(root).filter((ref) => ref.origin === 'user').map((ref) => refIdentity(ref.source, ref.component)).sort();
232
+ return validateCompositionContractSource({ ...(existsSync(contractPath) ? { contract: readFileSync(contractPath, 'utf8') } : {}), ...(frame ? { frame } : {}), ...(copyDeck ? { copyDeck } : {}), ...(typeProof ? { typeProof } : {}), ...(scout ? { scout } : {}), ...(userRefLabels.length ? { userRefLabels } : {}) });
233
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shadow-only, advisory-only. Never gates: findings from this module carry no
3
+ * exit-code weight and are not wired into the CLI or `validateCompositionContract`.
4
+ * Scope note (doc == code): a per-section carrier-presence heuristic. It checks,
5
+ * section by section, whether a content section names a purposeful visual carrier,
6
+ * and references the `## Focal hierarchy` visual-mass budget only as framing in the
7
+ * advisory message. It does NOT yet compute a cross-section visual-mass budget, so
8
+ * it cannot exempt a deliberately quiet supporting section that spends its mass
9
+ * elsewhere; real cross-section budget subordination is a follow-up required before
10
+ * this advisory is promoted toward gating.
11
+ */
12
+
13
+ export type VisualRichnessRegister = 'quiet' | 'confident' | 'showpiece';
14
+
15
+ export interface VisualRichnessFinding {
16
+ id: 'CARRIER-ADVISORY';
17
+ section: string;
18
+ message: string;
19
+ severity: 'advisory';
20
+ }
21
+
22
+ export interface VisualRichnessInputs {
23
+ contract: string;
24
+ register?: VisualRichnessRegister;
25
+ }
26
+
27
+ /** Copied parsing convention from `core/composition-contract/index.ts` (`## ` H2 sections). */
28
+ function sections(markdown: string): Map<string, string> {
29
+ const result = new Map<string, string>();
30
+ const matches = [...markdown.matchAll(/^##\s+(.+?)\s*$/gm)];
31
+ for (let i = 0; i < matches.length; i++) {
32
+ const match = matches[i]!;
33
+ const start = match.index! + match[0].length;
34
+ const end = matches[i + 1]?.index ?? markdown.length;
35
+ result.set(match[1]!.trim(), markdown.slice(start, end).trim());
36
+ }
37
+ return result;
38
+ }
39
+
40
+ /**
41
+ * Sections whose subject matter is where a purposeful visual carrier would be
42
+ * declared. `Input fingerprint`, `Experience spine`, `Section dependency`,
43
+ * `Candidate axes`, and `Transfer boundary` are structural/process sections
44
+ * with no visual-carrier claim to make, so they are out of scope here.
45
+ */
46
+ const CONTENT_SECTIONS = [
47
+ 'Grid and alignment',
48
+ 'Density and visual mass',
49
+ 'Focal hierarchy',
50
+ 'Domain form grammar',
51
+ 'Media roles',
52
+ 'Responsive recomposition',
53
+ ] as const;
54
+
55
+ /** Broad carrier vocabulary: any purposeful non-decorative visual/alternate carrier. */
56
+ const BROAD_CARRIER = /typographic|concept-bearing|\bpattern\b|\bgradient\b|\bphoto|\bgraphic|illustration|\bchart|\bdiagram|\bicon|imagery|\bmedia\b|animation|\bmotion\b|data\s*visuali[sz]ation|infographic/i;
57
+
58
+ /**
59
+ * Stricter carrier vocabulary used for `showpiece`: excludes the bare word
60
+ * "media" (which trivially appears in the fixed `## Media roles` heading and
61
+ * its boilerplate prompt) and requires a specific, named carrier.
62
+ */
63
+ const STRICT_CARRIER = /typographic|concept-bearing|\bpattern\b|\bgradient\b|\bphoto|\bgraphic|illustration|\bchart|\bdiagram|\bicon|imagery|animation|\bmotion\b|data\s*visuali[sz]ation|infographic/i;
64
+
65
+ function inferRegister(parsed: Map<string, string>): VisualRichnessRegister {
66
+ const text = `${parsed.get('Density and visual mass') ?? ''} ${parsed.get('Focal hierarchy') ?? ''}`;
67
+ if (/\bshowpiece\b/i.test(text)) return 'showpiece';
68
+ if (/\bquiet\b/i.test(text)) return 'quiet';
69
+ return 'confident';
70
+ }
71
+
72
+ /**
73
+ * Pure, advisory-only evaluation: for each content section present in the
74
+ * contract, check whether it declares a purposeful visual carrier (a
75
+ * typographic block, a concept-bearing line, a pattern/gradient, real media,
76
+ * or an explicit alternate carrier). `quiet` registers are deliberately
77
+ * carrier-light by design and are not advised against; `showpiece` requires a
78
+ * specifically named carrier rather than the generic word "media".
79
+ */
80
+ export function evaluateVisualRichness(inputs: VisualRichnessInputs): VisualRichnessFinding[] {
81
+ const parsed = sections(inputs.contract);
82
+ const register = inputs.register ?? inferRegister(parsed);
83
+
84
+ if (register === 'quiet') return [];
85
+
86
+ const carrierPattern = register === 'showpiece' ? STRICT_CARRIER : BROAD_CARRIER;
87
+ const findings: VisualRichnessFinding[] = [];
88
+ for (const section of CONTENT_SECTIONS) {
89
+ const body = parsed.get(section);
90
+ if (!body) continue;
91
+ if (!carrierPattern.test(body)) {
92
+ findings.push({
93
+ id: 'CARRIER-ADVISORY',
94
+ section,
95
+ message: `within the visual-mass budget set by Focal hierarchy, "${section}" does not yet name a purposeful visual carrier (typographic block, concept-bearing line, pattern/gradient, media, or an explicit alternate carrier)`,
96
+ severity: 'advisory',
97
+ });
98
+ }
99
+ }
100
+ return findings;
101
+ }