@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,189 @@
|
|
|
1
|
+
import type { Ir, Node, Violation } from '../types.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic interaction-state rules over the IR.
|
|
5
|
+
*
|
|
6
|
+
* ── What the IR can measure ──────────────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* The DOM IR captures, per node:
|
|
9
|
+
* - node.name tagname + first CSS class, e.g. "input.email-field", "div.error-msg"
|
|
10
|
+
* - node.path full ancestor path, e.g. "form.contact/div.field/input"
|
|
11
|
+
* - node.interactive true for INPUT, SELECT, TEXTAREA, BUTTON, A, SUMMARY, role=button
|
|
12
|
+
* - node.text own text content (trimmed, ≤200 chars)
|
|
13
|
+
*
|
|
14
|
+
* It does NOT capture: aria-* attributes, data-* attributes, full class lists
|
|
15
|
+
* beyond the first class, JS event handlers, or CSS :hover/:focus pseudo-classes.
|
|
16
|
+
*
|
|
17
|
+
* ── What becomes a deterministic rule ────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* DESIGN-FORM-NO-ERROR
|
|
20
|
+
* When the IR contains at least one form-input node (INPUT/TEXTAREA/SELECT that
|
|
21
|
+
* is interactive) AND no node on the page carries a class name or text pattern
|
|
22
|
+
* associated with an error state, the form has no visible error affordance.
|
|
23
|
+
*
|
|
24
|
+
* Detection signals for error affordance (OR — any one suffices):
|
|
25
|
+
* - A node whose name contains ".error" or ".invalid" (class name evidence)
|
|
26
|
+
* - A text node whose text contains "error" / "오류" / "잘못" (copy evidence)
|
|
27
|
+
* - A node with role="alert" (ARIA error container — the canonical accessible pattern)
|
|
28
|
+
* - A node with aria-invalid="true" (ARIA field-level error signal)
|
|
29
|
+
*
|
|
30
|
+
* False-positive mitigation: the rule fires only when form inputs are present AND
|
|
31
|
+
* no error affordance exists at all — not when an affordance exists but looks wrong.
|
|
32
|
+
* A div.error-state with empty text still counts as present; we are checking
|
|
33
|
+
* structure, not copy quality.
|
|
34
|
+
*
|
|
35
|
+
* The ARIA signals (role=alert, aria-invalid) improve precision over class-name-only
|
|
36
|
+
* detection: a page that correctly implements ARIA error patterns without ".error"
|
|
37
|
+
* class names no longer produces a false positive.
|
|
38
|
+
*
|
|
39
|
+
* ── What stays prompt-only and why ───────────────────────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* LOADING state
|
|
42
|
+
* Skeleton, spinner, and shimmer elements are commonly built with libraries
|
|
43
|
+
* (react-loading-skeleton, Tailwind's animate-pulse) and inlined at runtime.
|
|
44
|
+
* Static IR captures the resting DOM; any loading overlay is usually hidden
|
|
45
|
+
* (display:none or opacity:0) and therefore invisible to the IR extractor
|
|
46
|
+
* (dom.ts skips elements with zero dimensions or display:none). Cannot detect
|
|
47
|
+
* without runtime JavaScript interaction simulation.
|
|
48
|
+
* → Covered by hand.agent.yaml and finish-pass.md rules.
|
|
49
|
+
*
|
|
50
|
+
* EMPTY state
|
|
51
|
+
* Empty states are rendered conditionally by the application — they are absent
|
|
52
|
+
* from the DOM when there is data. The IR captures the rendered page, which
|
|
53
|
+
* typically has data. Detecting "this list has an empty state" from the static
|
|
54
|
+
* DOM would require knowing which lists are data-driven vs. static (nav, footer,
|
|
55
|
+
* feature-list), which requires full application context.
|
|
56
|
+
* Furthermore, the false-positive rate would be unacceptably high: every <ul>,
|
|
57
|
+
* <ol>, and .list container would need to be evaluated, and most would fire on
|
|
58
|
+
* purely static lists (navigation, feature bullets, footer links).
|
|
59
|
+
* → Covered by hand.agent.yaml and finish-pass.md rules.
|
|
60
|
+
*
|
|
61
|
+
* SUCCESS state
|
|
62
|
+
* Success confirmation UIs (toast, inline text, page transition) are triggered
|
|
63
|
+
* by user actions (form submit, button click) and are absent from the resting DOM.
|
|
64
|
+
* No IR measurement can confirm they exist without simulating user actions.
|
|
65
|
+
* → Covered by hand.agent.yaml rules.
|
|
66
|
+
*
|
|
67
|
+
* DISABLED state quality
|
|
68
|
+
* Detecting whether disabled elements communicate why they are disabled requires
|
|
69
|
+
* reading the associated explanatory copy and its proximity to the disabled element
|
|
70
|
+
* — a layout and semantic reasoning task, not a structural one.
|
|
71
|
+
* The IR does capture `interactive` (which covers BUTTON/INPUT), but the disabled
|
|
72
|
+
* attribute is not captured, and reasoning about adjacent copy context requires
|
|
73
|
+
* the full DOM tree with aria relationships, which the IR does not store.
|
|
74
|
+
* → Covered by hand.agent.yaml rules.
|
|
75
|
+
*
|
|
76
|
+
* OFFLINE state
|
|
77
|
+
* Offline detection requires a service worker or navigator.onLine event handler.
|
|
78
|
+
* Neither is visible in the static DOM IR.
|
|
79
|
+
* → Covered by hand.agent.yaml rules.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
// ── DESIGN-FORM-NO-ERROR ─────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns true when a node is a form-input element that users can type into or
|
|
86
|
+
* select from. Buttons are excluded: they do not receive inline validation.
|
|
87
|
+
*/
|
|
88
|
+
function isFormInput(name: string, interactive: boolean): boolean {
|
|
89
|
+
if (!interactive) return false;
|
|
90
|
+
return name.startsWith('input') || name.startsWith('textarea') || name.startsWith('select');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns true when a node carries a structural signal that an error state exists:
|
|
95
|
+
* - Class name contains "error" or "invalid" (structural evidence)
|
|
96
|
+
* - Text content contains common error vocabulary (copy evidence)
|
|
97
|
+
* - node.role === 'alert' (ARIA error container — the canonical accessible pattern)
|
|
98
|
+
* - node.ariaInvalid === true (ARIA field-level error signal)
|
|
99
|
+
*
|
|
100
|
+
* This is an OR check — any one signal suffices to suppress the violation.
|
|
101
|
+
* The ARIA signals were added to reduce false positives: pages that correctly use
|
|
102
|
+
* role=alert or aria-invalid without ".error" class names no longer trigger.
|
|
103
|
+
*/
|
|
104
|
+
function hasErrorAffordance(node: Node): boolean {
|
|
105
|
+
const { name, text, role, ariaInvalid } = node;
|
|
106
|
+
|
|
107
|
+
// ARIA evidence: role=alert is the canonical container for live error messages;
|
|
108
|
+
// aria-invalid=true is the field-level signal that a value failed validation.
|
|
109
|
+
if (role === 'alert' || role === 'alertdialog') return true;
|
|
110
|
+
if (ariaInvalid === true) return true;
|
|
111
|
+
|
|
112
|
+
// Class-name evidence: "div.error", "span.invalid", "p.error-message", "input.is-invalid"
|
|
113
|
+
const lower = name.toLowerCase();
|
|
114
|
+
if (
|
|
115
|
+
lower.includes('.error') ||
|
|
116
|
+
lower.includes('.invalid') ||
|
|
117
|
+
lower.includes('error-') ||
|
|
118
|
+
lower.includes('-error') ||
|
|
119
|
+
lower.includes('invalid-') ||
|
|
120
|
+
lower.includes('-invalid')
|
|
121
|
+
) return true;
|
|
122
|
+
|
|
123
|
+
// Text-content evidence: the rendered page shows an error message
|
|
124
|
+
if (text) {
|
|
125
|
+
const t = text.toLowerCase();
|
|
126
|
+
if (
|
|
127
|
+
t.includes('error') ||
|
|
128
|
+
t.includes('오류') ||
|
|
129
|
+
t.includes('잘못') ||
|
|
130
|
+
t.includes('올바르지') ||
|
|
131
|
+
t.includes('invalid') ||
|
|
132
|
+
t.includes('required') ||
|
|
133
|
+
t.includes('필수')
|
|
134
|
+
) return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Run deterministic interaction-state checks over the IR.
|
|
142
|
+
*
|
|
143
|
+
* Currently implements one deterministic rule (DESIGN-FORM-NO-ERROR).
|
|
144
|
+
* All other interaction states are prompt-only; see the file-level comment above.
|
|
145
|
+
*
|
|
146
|
+
* Only call this from `omd check` when the page has interactive elements —
|
|
147
|
+
* it is always safe to call regardless, but pages with no inputs will produce
|
|
148
|
+
* no findings (the form-input guard suppresses the rule).
|
|
149
|
+
*/
|
|
150
|
+
export function checkInteractionStates(ir: Ir): Violation[] {
|
|
151
|
+
const violations: Violation[] = [];
|
|
152
|
+
|
|
153
|
+
// ── DESIGN-FORM-NO-ERROR ──────────────────────────────────────────────────
|
|
154
|
+
//
|
|
155
|
+
// Fires when: at least one form-input node is present AND no node on the page
|
|
156
|
+
// carries an error-state affordance (class or text pattern).
|
|
157
|
+
//
|
|
158
|
+
// The rule fires once at the page level, not once per input — a form design
|
|
159
|
+
// either has error states or it does not. Firing once per input would produce
|
|
160
|
+
// redundant noise on multi-field forms.
|
|
161
|
+
|
|
162
|
+
const formInputs = ir.nodes.filter((n) => isFormInput(n.name, n.interactive === true));
|
|
163
|
+
|
|
164
|
+
if (formInputs.length > 0) {
|
|
165
|
+
const anyError = ir.nodes.some((n) => hasErrorAffordance(n));
|
|
166
|
+
|
|
167
|
+
if (!anyError) {
|
|
168
|
+
// Report against the first input node's path for location context.
|
|
169
|
+
const representative = formInputs[0]!;
|
|
170
|
+
violations.push({
|
|
171
|
+
id: 'DESIGN-FORM-NO-ERROR',
|
|
172
|
+
severity: 'warn',
|
|
173
|
+
layer: 1,
|
|
174
|
+
category: 'system',
|
|
175
|
+
nodeId: representative.id,
|
|
176
|
+
path: representative.path,
|
|
177
|
+
value: `${formInputs.length} form input${formInputs.length === 1 ? '' : 's'}, 0 error affordances`,
|
|
178
|
+
message:
|
|
179
|
+
`This page has ${formInputs.length} form input${formInputs.length === 1 ? '' : 's'} but no visible error-state affordance. `
|
|
180
|
+
+ 'Add error-styled elements (class names containing "error" or "invalid") and error message copy. '
|
|
181
|
+
+ 'Every field that can fail validation must show what went wrong and what to fix — '
|
|
182
|
+
+ 'role=alert on the error container, aria-invalid on the failed field. '
|
|
183
|
+
+ 'A form without an error state is an unfinished interface.',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return violations;
|
|
189
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 0 eval harness — pure decision core.
|
|
3
|
+
*
|
|
4
|
+
* Pairwise before/after blind-choose is the ONLY gating signal. Quantitative metrics
|
|
5
|
+
* (non-text carrier density, motion presence, image-diff score/threshold/pass — see
|
|
6
|
+
* `DiffResult` in core/figma/diff.ts for that shape) and Awwwards-style category
|
|
7
|
+
* scores (design/usability/creativity/content) are advisory: they may explain or
|
|
8
|
+
* corroborate a verdict, but they can never flip `gate` from what blindChoose decided.
|
|
9
|
+
*
|
|
10
|
+
* NOT implemented here: a live render+blind-choose capture adapter (rendering both
|
|
11
|
+
* variants and recording a human/model's blind pick) is a Phase 2 dependency — it
|
|
12
|
+
* needs a browser/render runtime (see core/render/index.ts's Playwright-backed
|
|
13
|
+
* `withPage`). This module is the pure decision core that consumes pre-captured
|
|
14
|
+
* blindChoose signals; it does not render or capture anything itself. Follow the
|
|
15
|
+
* pure-core/adapter split used by core/composition-contract/index.ts
|
|
16
|
+
* (validateCompositionContractSource = pure, validateCompositionContract = adapter):
|
|
17
|
+
* a future `captureBlindChoose(...)` adapter would live alongside this file once the
|
|
18
|
+
* render runtime is available, calling into `evaluateOutput` below for the decision.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type BlindChooseWinner = 'after' | 'before' | 'tie';
|
|
22
|
+
|
|
23
|
+
export interface BlindChooseSignal {
|
|
24
|
+
winner: BlindChooseWinner;
|
|
25
|
+
/** Confidence in [0, 1], when the capture method can report one. */
|
|
26
|
+
confidence?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Register = 'quiet' | 'confident' | 'showpiece';
|
|
30
|
+
|
|
31
|
+
export interface QuantitativeSignals {
|
|
32
|
+
/** 0–1 fraction of the layout carried by non-text elements (imagery, shape, color). */
|
|
33
|
+
nonTextCarrierDensity?: number | undefined;
|
|
34
|
+
motionPresent?: boolean | undefined;
|
|
35
|
+
/** Reserved for image-diff-style signals (score/threshold/pass) or other numeric/boolean metrics. */
|
|
36
|
+
[key: string]: number | boolean | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AwwwardsSignals {
|
|
40
|
+
design?: number | undefined;
|
|
41
|
+
usability?: number | undefined;
|
|
42
|
+
creativity?: number | undefined;
|
|
43
|
+
content?: number | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EvalHarnessInputs {
|
|
47
|
+
blindChoose: BlindChooseSignal;
|
|
48
|
+
register?: Register;
|
|
49
|
+
quantitative?: QuantitativeSignals;
|
|
50
|
+
awwwards?: AwwwardsSignals;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface EvalAdvisory {
|
|
54
|
+
source: 'quantitative' | 'awwwards';
|
|
55
|
+
message: string;
|
|
56
|
+
gating: false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EvalVerdict {
|
|
60
|
+
gate: 'pass' | 'fail';
|
|
61
|
+
gatingSignal: 'blind-choose';
|
|
62
|
+
reason: string;
|
|
63
|
+
advisories: EvalAdvisory[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Showpiece register requires a stronger blind-choose confidence to pass the gate. */
|
|
67
|
+
const SHOWPIECE_CONFIDENCE_THRESHOLD = 0.6;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Determines the gate from blindChoose alone. Quiet/confident/showpiece registers only
|
|
71
|
+
* adjust how blindChoose itself is interpreted (e.g. a showpiece needs a confident win,
|
|
72
|
+
* a quiet output is satisfied by any non-"before" verdict) — they never let quantitative
|
|
73
|
+
* or Awwwards signals override the blind-choose outcome.
|
|
74
|
+
*/
|
|
75
|
+
function gateFromBlindChoose(blindChoose: BlindChooseSignal, register: Register): { pass: boolean; reason: string } {
|
|
76
|
+
const { winner, confidence } = blindChoose;
|
|
77
|
+
|
|
78
|
+
if (winner === 'before') {
|
|
79
|
+
return { pass: false, reason: 'blind-choose winner was "before": the after variant did not improve on the baseline' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (winner === 'tie') {
|
|
83
|
+
if (register === 'quiet' && confidence === undefined) {
|
|
84
|
+
// Quiet register: a tie with no reported confidence is treated as "no regression",
|
|
85
|
+
// which is the bar quiet output is held to. Still requires an explicit tie, never
|
|
86
|
+
// inferred from quantitative/Awwwards data.
|
|
87
|
+
return { pass: true, reason: 'blind-choose was a tie and register is "quiet": no regression is sufficient' };
|
|
88
|
+
}
|
|
89
|
+
return { pass: false, reason: 'blind-choose winner was "tie": no clear preference for the after variant' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// winner === 'after'
|
|
93
|
+
if (register === 'showpiece') {
|
|
94
|
+
if (confidence === undefined || !Number.isFinite(confidence) || confidence < SHOWPIECE_CONFIDENCE_THRESHOLD) {
|
|
95
|
+
return {
|
|
96
|
+
pass: false,
|
|
97
|
+
reason: `register is "showpiece" and requires blind-choose confidence >= ${SHOWPIECE_CONFIDENCE_THRESHOLD}, got ${confidence ?? 'undefined'}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { pass: true, reason: `blind-choose winner was "after" with confidence ${confidence} meeting the showpiece threshold` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { pass: true, reason: 'blind-choose winner was "after"' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function quantitativeAdvisories(quantitative: QuantitativeSignals | undefined): EvalAdvisory[] {
|
|
107
|
+
if (!quantitative) return [];
|
|
108
|
+
const advisories: EvalAdvisory[] = [];
|
|
109
|
+
for (const [key, value] of Object.entries(quantitative)) {
|
|
110
|
+
if (value === undefined) continue;
|
|
111
|
+
advisories.push({ source: 'quantitative', message: `${key}: ${value}`, gating: false });
|
|
112
|
+
}
|
|
113
|
+
return advisories;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function awwwardsAdvisories(awwwards: AwwwardsSignals | undefined): EvalAdvisory[] {
|
|
117
|
+
if (!awwwards) return [];
|
|
118
|
+
const advisories: EvalAdvisory[] = [];
|
|
119
|
+
for (const [key, value] of Object.entries(awwwards)) {
|
|
120
|
+
if (value === undefined) continue;
|
|
121
|
+
advisories.push({ source: 'awwwards', message: `${key}: ${value}`, gating: false });
|
|
122
|
+
}
|
|
123
|
+
return advisories;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Pure evaluation core: blind-choose is the sole gating signal (see gateFromBlindChoose).
|
|
128
|
+
* Quantitative metrics and Awwwards category scores are folded into `advisories` only,
|
|
129
|
+
* each explicitly marked `gating: false` — no code path lets them alter `gate`.
|
|
130
|
+
*/
|
|
131
|
+
export function evaluateOutput(inputs: EvalHarnessInputs): EvalVerdict {
|
|
132
|
+
const register = inputs.register ?? 'confident';
|
|
133
|
+
const { pass, reason } = gateFromBlindChoose(inputs.blindChoose, register);
|
|
134
|
+
|
|
135
|
+
const advisories: EvalAdvisory[] = [
|
|
136
|
+
...quantitativeAdvisories(inputs.quantitative),
|
|
137
|
+
...awwwardsAdvisories(inputs.awwwards),
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
gate: pass ? 'pass' : 'fail',
|
|
142
|
+
gatingSignal: 'blind-choose',
|
|
143
|
+
reason,
|
|
144
|
+
advisories,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One completed refinement round in the RED/GREEN loop. `redCriteria` are the acceptance
|
|
150
|
+
* criteria still failing after this round (empty = GREEN, all met). `evidence` is the durable
|
|
151
|
+
* proof this round actually happened — render paths, verdict artifacts, `omd decision` ids — and
|
|
152
|
+
* is REQUIRED: a round with no evidence does not count. `blindChoose` compares this round's after
|
|
153
|
+
* against its own before, giving the improvement direction.
|
|
154
|
+
*/
|
|
155
|
+
export interface RefinementRound {
|
|
156
|
+
round: number;
|
|
157
|
+
blindChoose: BlindChooseWinner;
|
|
158
|
+
redCriteria: string[];
|
|
159
|
+
evidence: string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface RefinementLoopInputs {
|
|
163
|
+
rounds: RefinementRound[];
|
|
164
|
+
/** Hard upper bound on rounds — the loop is never unbounded. Default 3. */
|
|
165
|
+
maxRounds?: number;
|
|
166
|
+
/** Rounds to run before a plateau/GREEN stop is honored. Default 1. */
|
|
167
|
+
minRounds?: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type RefinementLoopStatus =
|
|
171
|
+
| 'run-first-round'
|
|
172
|
+
| 'continue'
|
|
173
|
+
| 'green'
|
|
174
|
+
| 'regressed'
|
|
175
|
+
| 'plateaued'
|
|
176
|
+
| 'budget-exhausted'
|
|
177
|
+
| 'missing-evidence';
|
|
178
|
+
|
|
179
|
+
export interface RefinementLoopDecision {
|
|
180
|
+
status: RefinementLoopStatus;
|
|
181
|
+
continueLoop: boolean;
|
|
182
|
+
converged: boolean;
|
|
183
|
+
reason: string;
|
|
184
|
+
roundsRun: number;
|
|
185
|
+
/** The top unmet (RED) criterion to target next when continuing; null otherwise. */
|
|
186
|
+
nextTarget: string | null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Bounded RED/GREEN refinement-loop controller. It is NOT an automatic retry loop. Each round
|
|
191
|
+
* MUST leave evidence; the loop continues only while the build is still RED (acceptance criteria
|
|
192
|
+
* unmet) AND blind-choose shows the latest round genuinely improved it AND the round budget
|
|
193
|
+
* remains. It stops on the first of — GREEN (all criteria met = done), a regression (revert), a
|
|
194
|
+
* plateau (tie while still RED), a budget exhaustion, or missing evidence. This is the deliberate,
|
|
195
|
+
* evidence-driven convergence loop `protocol/human-design-loop.md` permits.
|
|
196
|
+
*/
|
|
197
|
+
export function evaluateRefinementLoop(inputs: RefinementLoopInputs): RefinementLoopDecision {
|
|
198
|
+
const maxRounds = inputs.maxRounds ?? 3;
|
|
199
|
+
const minRounds = inputs.minRounds ?? 1;
|
|
200
|
+
const rounds = inputs.rounds;
|
|
201
|
+
const n = rounds.length;
|
|
202
|
+
|
|
203
|
+
if (n === 0) {
|
|
204
|
+
return { status: 'run-first-round', continueLoop: true, converged: false, reason: 'no refinement round run yet — run the first round and record its evidence', roundsRun: 0, nextTarget: null };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const last = rounds[n - 1]!;
|
|
208
|
+
|
|
209
|
+
if (last.evidence.length === 0) {
|
|
210
|
+
return { status: 'missing-evidence', continueLoop: false, converged: false, reason: `round ${last.round} recorded no evidence — a refinement round without evidence (renders + blind-choose verdict) does not count; record it before deciding`, roundsRun: n, nextTarget: null };
|
|
211
|
+
}
|
|
212
|
+
if (last.blindChoose === 'before') {
|
|
213
|
+
return { status: 'regressed', continueLoop: false, converged: false, reason: `round ${last.round} regressed (blind-choose favored the before) — revert to the previous build and stop`, roundsRun: n, nextTarget: null };
|
|
214
|
+
}
|
|
215
|
+
if (last.redCriteria.length === 0 && n >= minRounds) {
|
|
216
|
+
return { status: 'green', continueLoop: false, converged: true, reason: `round ${last.round} is GREEN — every acceptance criterion is met; stop`, roundsRun: n, nextTarget: null };
|
|
217
|
+
}
|
|
218
|
+
if (n >= maxRounds) {
|
|
219
|
+
return { status: 'budget-exhausted', continueLoop: false, converged: false, reason: `round budget reached (${maxRounds}) — stop with the best build so far; still RED: ${last.redCriteria.join(', ') || '(none reported)'}`, roundsRun: n, nextTarget: null };
|
|
220
|
+
}
|
|
221
|
+
if (last.blindChoose === 'tie' && n >= minRounds) {
|
|
222
|
+
return { status: 'plateaued', continueLoop: false, converged: false, reason: `round ${last.round} plateaued (blind-choose tie) while still RED — no further improvement available; stop and report remaining: ${last.redCriteria.join(', ')}`, roundsRun: n, nextTarget: null };
|
|
223
|
+
}
|
|
224
|
+
return { status: 'continue', continueLoop: true, converged: false, reason: `round ${last.round} improved (blind-choose favored the after) and is still RED — run another round`, roundsRun: n, nextTarget: last.redCriteria[0] ?? null };
|
|
225
|
+
}
|