@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,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
|
+
}
|