@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,94 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import { capturePageForRef, withBrowser, parseViewport } from '../render/index.ts';
|
|
3
|
+
import { normalize } from '../ir/normalize.ts';
|
|
4
|
+
import { extractInvariants } from './invariants.ts';
|
|
5
|
+
import { captureBlueprint } from './blueprint.ts';
|
|
6
|
+
import { saveRef, refImagePath } from './store.ts';
|
|
7
|
+
import { loadRules, check } from '../rules/engine.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One reference to capture in a batch. Same shape as an `omd ref add --selector … --blueprint --shot`
|
|
11
|
+
* call, minus the energy (motion) pass: batch capture is optimised for the common non-motion board,
|
|
12
|
+
* so a motion reference that needs an energy study goes through the single-ref `omd ref add` path.
|
|
13
|
+
*/
|
|
14
|
+
export interface RefSpec {
|
|
15
|
+
source: string;
|
|
16
|
+
as: string;
|
|
17
|
+
selector?: string;
|
|
18
|
+
blueprint?: boolean;
|
|
19
|
+
shot?: boolean;
|
|
20
|
+
fromUser?: boolean;
|
|
21
|
+
viewport?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BatchOutcome {
|
|
25
|
+
source: string;
|
|
26
|
+
as: string;
|
|
27
|
+
ok: boolean;
|
|
28
|
+
error?: string;
|
|
29
|
+
slopCount?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BatchResult {
|
|
33
|
+
concurrency: number;
|
|
34
|
+
outcomes: BatchOutcome[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Capture many references concurrently over ONE shared browser. Each reference is one page and one
|
|
39
|
+
* navigation (IR plus an optional scoped screenshot), so the board is built in a fraction of the
|
|
40
|
+
* wall time of one-browser-launch-per-reference — and with byte-identical per-reference results,
|
|
41
|
+
* so the output the board feeds is unchanged. A single reference failing never fails the batch.
|
|
42
|
+
*/
|
|
43
|
+
export async function addRefsBatch(
|
|
44
|
+
cwd: string,
|
|
45
|
+
specs: RefSpec[],
|
|
46
|
+
opts: { rulesRoot: string; concurrency?: number },
|
|
47
|
+
): Promise<BatchResult> {
|
|
48
|
+
const concurrency = Math.max(1, opts.concurrency ?? 4);
|
|
49
|
+
const rules = loadRules(opts.rulesRoot);
|
|
50
|
+
const outcomes: BatchOutcome[] = new Array<BatchOutcome>(specs.length);
|
|
51
|
+
|
|
52
|
+
await withBrowser(async (browser) => {
|
|
53
|
+
let next = 0;
|
|
54
|
+
const worker = async (): Promise<void> => {
|
|
55
|
+
for (;;) {
|
|
56
|
+
const i = next++;
|
|
57
|
+
if (i >= specs.length) return;
|
|
58
|
+
const spec = specs[i]!;
|
|
59
|
+
try {
|
|
60
|
+
const shotOut = spec.shot && spec.selector
|
|
61
|
+
? refImagePath(cwd, { source: spec.source, component: spec.as })
|
|
62
|
+
: undefined;
|
|
63
|
+
const { raw, shotSaved } = await capturePageForRef(browser, spec.source, parseViewport(spec.viewport), {
|
|
64
|
+
selector: spec.selector ?? null,
|
|
65
|
+
...(shotOut ? { shotOut } : {}),
|
|
66
|
+
});
|
|
67
|
+
const ir = normalize(raw);
|
|
68
|
+
const invariants = extractInvariants(ir);
|
|
69
|
+
const slopCount = check(ir, rules, { categories: ['slop'] }).length;
|
|
70
|
+
const blueprint = spec.blueprint && spec.selector ? captureBlueprint(raw.nodes, spec.selector) : undefined;
|
|
71
|
+
saveRef(cwd, {
|
|
72
|
+
source: spec.source,
|
|
73
|
+
component: spec.as,
|
|
74
|
+
kind: spec.selector ? 'component' : 'page',
|
|
75
|
+
capturedAt: new Date().toISOString(),
|
|
76
|
+
...(spec.selector ? { selector: spec.selector } : {}),
|
|
77
|
+
invariants,
|
|
78
|
+
principles: [],
|
|
79
|
+
slopCount,
|
|
80
|
+
...(spec.fromUser ? { origin: 'user' as const } : {}),
|
|
81
|
+
...(blueprint !== undefined ? { blueprint } : {}),
|
|
82
|
+
...(shotSaved && shotOut ? { imagePath: relative(cwd, shotOut) } : {}),
|
|
83
|
+
});
|
|
84
|
+
outcomes[i] = { source: spec.source, as: spec.as, ok: true, slopCount };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
outcomes[i] = { source: spec.source, as: spec.as, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, specs.length) }, () => worker()));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return { concurrency, outcomes };
|
|
94
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { RawNode } from '../types.ts';
|
|
2
|
+
import type { Blueprint, BlueprintNode, ColorRole, NodeRole, TextLengthClass } from '../types.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WCAG relative luminance from a hex string like '#AABBCC'.
|
|
6
|
+
* Returns 0 for unrecognised values.
|
|
7
|
+
*/
|
|
8
|
+
export function relativeLuminance(hex: string): number {
|
|
9
|
+
const clean = hex.replace('#', '');
|
|
10
|
+
if (clean.length !== 6) return 0;
|
|
11
|
+
const f = (c: number): number => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
12
|
+
const r = f(parseInt(clean.slice(0, 2), 16) / 255);
|
|
13
|
+
const g = f(parseInt(clean.slice(2, 4), 16) / 255);
|
|
14
|
+
const b = f(parseInt(clean.slice(4, 6), 16) / 255);
|
|
15
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* HSL saturation (0..1) from a hex string. Near-grey colors return 0.
|
|
20
|
+
* Used to identify accent colors that stand out by hue rather than by luminance.
|
|
21
|
+
*/
|
|
22
|
+
export function hexSaturation(hex: string): number {
|
|
23
|
+
const clean = hex.replace('#', '');
|
|
24
|
+
if (clean.length !== 6) return 0;
|
|
25
|
+
const r = parseInt(clean.slice(0, 2), 16) / 255;
|
|
26
|
+
const g = parseInt(clean.slice(2, 4), 16) / 255;
|
|
27
|
+
const b = parseInt(clean.slice(4, 6), 16) / 255;
|
|
28
|
+
const max = Math.max(r, g, b);
|
|
29
|
+
const min = Math.min(r, g, b);
|
|
30
|
+
if (max === min) return 0;
|
|
31
|
+
const l = (max + min) / 2;
|
|
32
|
+
const d = max - min;
|
|
33
|
+
return l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ColorEntry {
|
|
37
|
+
hex: string;
|
|
38
|
+
count: number;
|
|
39
|
+
/** True for background/fill colors; false for text colors. */
|
|
40
|
+
isFill: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cluster component colors into semantic roles using usage frequency and relative luminance.
|
|
45
|
+
* Literal hex values never leave this function; only roles are stored in the blueprint.
|
|
46
|
+
*
|
|
47
|
+
* Assignment order (each hex is claimed by the first matching rule):
|
|
48
|
+
* 1. accent — minority color (count < max) with highest HSL saturation > 0.25;
|
|
49
|
+
* text colors are preferred over fills since interactive elements often use text
|
|
50
|
+
* 2. bg — most-frequent fill (darker fill wins ties)
|
|
51
|
+
* 3. surface — remaining fills (elevated panels, cards)
|
|
52
|
+
* 4. fg — most-frequent text color (darker text wins ties)
|
|
53
|
+
* 5. muted — remaining text colors
|
|
54
|
+
*
|
|
55
|
+
* This is a pure function: identical input → identical output.
|
|
56
|
+
*/
|
|
57
|
+
export function clusterColorRoles(colors: ColorEntry[]): Map<string, ColorRole> {
|
|
58
|
+
const result = new Map<string, ColorRole>();
|
|
59
|
+
if (colors.length === 0) return result;
|
|
60
|
+
|
|
61
|
+
const maxCount = Math.max(...colors.map((c) => c.count));
|
|
62
|
+
|
|
63
|
+
// Accent: a minority saturated color that stands out from the structural palette.
|
|
64
|
+
// Text colors are preferred (CTAs, links), then fills (branded buttons).
|
|
65
|
+
// A color at max frequency is structural — it cannot be an accent.
|
|
66
|
+
const accentCandidate = [...colors]
|
|
67
|
+
.filter((c) => c.count < maxCount && hexSaturation(c.hex) > 0.25)
|
|
68
|
+
.sort((a, b) => {
|
|
69
|
+
// Text colors first; within same kind, higher saturation wins.
|
|
70
|
+
if (a.isFill !== b.isFill) return a.isFill ? 1 : -1;
|
|
71
|
+
return hexSaturation(b.hex) - hexSaturation(a.hex);
|
|
72
|
+
})[0];
|
|
73
|
+
if (accentCandidate) result.set(accentCandidate.hex, 'accent');
|
|
74
|
+
|
|
75
|
+
// Fills: most-frequent → bg; rest → surface.
|
|
76
|
+
// Ties in frequency are broken by luminance (darker fill = more dominant backdrop).
|
|
77
|
+
const fills = colors
|
|
78
|
+
.filter((c) => c.isFill && !result.has(c.hex))
|
|
79
|
+
.sort((a, b) => b.count - a.count || relativeLuminance(a.hex) - relativeLuminance(b.hex));
|
|
80
|
+
fills.forEach((f, i) => result.set(f.hex, i === 0 ? 'bg' : 'surface'));
|
|
81
|
+
|
|
82
|
+
// Text colors: most-frequent → fg; rest → muted.
|
|
83
|
+
const texts = colors
|
|
84
|
+
.filter((c) => !c.isFill && !result.has(c.hex))
|
|
85
|
+
.sort((a, b) => b.count - a.count || relativeLuminance(a.hex) - relativeLuminance(b.hex));
|
|
86
|
+
texts.forEach((t, i) => result.set(t.hex, i === 0 ? 'fg' : 'muted'));
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Derive the semantic role of a node from its IR fields. */
|
|
92
|
+
export function deriveNodeRole(node: Pick<RawNode, 'type' | 'interactive' | 'heading' | 'name'>): NodeRole {
|
|
93
|
+
if (node.interactive) return 'interactive';
|
|
94
|
+
if (node.heading !== undefined) return 'heading';
|
|
95
|
+
if (node.type === 'TEXT') return 'text';
|
|
96
|
+
// Heuristic for image elements: the DOM extractor encodes the tag name as the first
|
|
97
|
+
// segment of node.name (tagname.firstClass). Match common image container tags.
|
|
98
|
+
const tag = (node.name.split('.')[0] ?? '').toLowerCase();
|
|
99
|
+
if (tag === 'img' || tag === 'svg' || tag === 'picture' || tag === 'figure') return 'image';
|
|
100
|
+
return 'container';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Derive the text length class from node text content. Copy is never stored. */
|
|
104
|
+
export function deriveTextLength(text: string): TextLengthClass {
|
|
105
|
+
if (text.length <= 20) return 'label';
|
|
106
|
+
if (text.length <= 80) return 'phrase';
|
|
107
|
+
return 'paragraph';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build a Blueprint from a component's raw nodes.
|
|
112
|
+
*
|
|
113
|
+
* What is stored per node: role, box dimensions, layout metrics (padding, gap, direction),
|
|
114
|
+
* typography metrics (fontSize, fontWeight, lineHeight), surface geometry (radius, shadow),
|
|
115
|
+
* color roles (bg/surface/fg/muted/accent — no literal hex values), motion timings, and a
|
|
116
|
+
* text length class (label/phrase/paragraph — no actual copy).
|
|
117
|
+
*
|
|
118
|
+
* What is NOT stored: text content, literal color values, alignment (not measured by the
|
|
119
|
+
* IR extractor), border details (not measured by the IR extractor).
|
|
120
|
+
*
|
|
121
|
+
* Inherited fills — transparent nodes that show an ancestor's backdrop — are excluded from
|
|
122
|
+
* the color inventory and from each node's fillRole so the component's own palette is what
|
|
123
|
+
* gets clustered.
|
|
124
|
+
*
|
|
125
|
+
* @param nodes Raw nodes from extractIr, already scoped to the selector.
|
|
126
|
+
* @param selector The CSS selector that bounded this capture (stored as metadata).
|
|
127
|
+
*/
|
|
128
|
+
export function captureBlueprint(nodes: RawNode[], selector: string): Blueprint {
|
|
129
|
+
// Build color frequency map. Keyed by hex+kind so the same color can appear as both a
|
|
130
|
+
// fill (background) and a text color — they are different roles in different slots.
|
|
131
|
+
const colorMap = new Map<string, ColorEntry>();
|
|
132
|
+
const trackColor = (hex: string, isFill: boolean): void => {
|
|
133
|
+
const key = `${hex}:${isFill ? 'f' : 't'}`;
|
|
134
|
+
const entry = colorMap.get(key);
|
|
135
|
+
if (entry) entry.count += 1;
|
|
136
|
+
else colorMap.set(key, { hex, count: 1, isFill });
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
for (const node of nodes) {
|
|
140
|
+
// Only authored (non-inherited) fills represent the node's own background.
|
|
141
|
+
const fill = node.fill;
|
|
142
|
+
if (fill && typeof fill.value === 'string' && !fill.inherited) {
|
|
143
|
+
trackColor(fill.value, true);
|
|
144
|
+
}
|
|
145
|
+
if (node.color) trackColor(node.color, false);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const colorRoleMap = clusterColorRoles([...colorMap.values()]);
|
|
149
|
+
|
|
150
|
+
const bpNodes: BlueprintNode[] = nodes.map((node): BlueprintNode => {
|
|
151
|
+
const role = deriveNodeRole(node);
|
|
152
|
+
|
|
153
|
+
const bp: BlueprintNode = {
|
|
154
|
+
id: node.id,
|
|
155
|
+
role,
|
|
156
|
+
children: [...node.children],
|
|
157
|
+
box: { w: node.box.w, h: node.box.h },
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Layout: store padding and gap only when non-trivial; direction always when present.
|
|
161
|
+
if (node.layout) {
|
|
162
|
+
bp.padding = node.layout.padding;
|
|
163
|
+
if (node.layout.gap > 0) bp.gap = node.layout.gap;
|
|
164
|
+
bp.direction = node.layout.mode;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Typography: only the fields the extractor actually measured.
|
|
168
|
+
if (node.fontSize != null) bp.fontSize = node.fontSize;
|
|
169
|
+
if (node.fontWeight != null) bp.fontWeight = node.fontWeight;
|
|
170
|
+
if (node.lineHeight != null) bp.lineHeight = node.lineHeight;
|
|
171
|
+
|
|
172
|
+
// Surface geometry.
|
|
173
|
+
const rv = node.radius?.value;
|
|
174
|
+
if (typeof rv === 'number' && rv > 0) bp.radius = rv;
|
|
175
|
+
if (node.shadow?.value) bp.hasShadow = true;
|
|
176
|
+
|
|
177
|
+
// Color roles — literal hexes never leave this function.
|
|
178
|
+
const fillHex = node.fill?.value;
|
|
179
|
+
if (typeof fillHex === 'string' && !node.fill?.inherited) {
|
|
180
|
+
const cr = colorRoleMap.get(fillHex);
|
|
181
|
+
if (cr) bp.fillRole = cr;
|
|
182
|
+
}
|
|
183
|
+
if (node.color) {
|
|
184
|
+
const cr = colorRoleMap.get(node.color);
|
|
185
|
+
if (cr) bp.textRole = cr;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Motion timings: durations and easings, no animation names.
|
|
189
|
+
if (node.motion?.durations?.length) bp.motionDurations = [...node.motion.durations];
|
|
190
|
+
if (node.motion?.easings?.length) bp.motionEasings = [...node.motion.easings];
|
|
191
|
+
|
|
192
|
+
// Text length class — content stripped, only length signal remains.
|
|
193
|
+
if (node.text) bp.textLength = deriveTextLength(node.text);
|
|
194
|
+
|
|
195
|
+
return bp;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return { selector, capturedAt: new Date().toISOString(), nodes: bpNodes };
|
|
199
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { Invariants, Reference, RefDistance } from '../types.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Jaccard: shared rungs over total distinct rungs.
|
|
5
|
+
*
|
|
6
|
+
* The question is not "does this page use 8px" — nearly every page does. It is "does this
|
|
7
|
+
* page have the same rhythm": the same rungs, and no others. Two designs on the 8pt grid
|
|
8
|
+
* share 4/8/16 by convention, not by copying, and Jaccard's union denominator is what
|
|
9
|
+
* charges them for the rungs they do not share.
|
|
10
|
+
*
|
|
11
|
+
* Measured against Szymkiewicz–Simpson overlap, which normalises by the shorter ladder:
|
|
12
|
+
* that scored an ordinary five-rung 8pt scale at 0.82 against Linear's thirteen rungs,
|
|
13
|
+
* because a short ladder sits inside a long one by construction. Containment is not
|
|
14
|
+
* similarity.
|
|
15
|
+
*/
|
|
16
|
+
function jaccard(a: number[], b: number[]): number {
|
|
17
|
+
const setA = new Set(a);
|
|
18
|
+
const setB = new Set(b);
|
|
19
|
+
if (setA.size === 0 && setB.size === 0) return 1;
|
|
20
|
+
|
|
21
|
+
let shared = 0;
|
|
22
|
+
for (const v of setA) if (setB.has(v)) shared += 1;
|
|
23
|
+
const union = setA.size + setB.size - shared;
|
|
24
|
+
return union === 0 ? 1 : shared / union;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Same idea as `jaccard`, over strings: font families, easing functions. */
|
|
28
|
+
function jaccardStrings(a: string[], b: string[]): number {
|
|
29
|
+
const setA = new Set(a);
|
|
30
|
+
const setB = new Set(b);
|
|
31
|
+
if (setA.size === 0 && setB.size === 0) return 1;
|
|
32
|
+
|
|
33
|
+
let shared = 0;
|
|
34
|
+
for (const v of setA) if (setB.has(v)) shared += 1;
|
|
35
|
+
const union = setA.size + setB.size - shared;
|
|
36
|
+
return union === 0 ? 1 : shared / union;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ratioDiff = (a: number, b: number): number => 1 - Math.abs(a - b) / Math.max(a, b, 1);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Padding is felt as a proportion, not a difference: 10px against 40px is a different
|
|
43
|
+
* design, 100px against 130px is the same design. `ratioDiff` disagreed, scoring the
|
|
44
|
+
* identical 30px gap 0.25 and 0.77. Comparing logs makes the metric scale-free.
|
|
45
|
+
*/
|
|
46
|
+
function logRatio(a: number, b: number): number {
|
|
47
|
+
if (a === b) return 1;
|
|
48
|
+
const la = Math.log1p(Math.max(a, 0));
|
|
49
|
+
const lb = Math.log1p(Math.max(b, 0));
|
|
50
|
+
const span = Math.max(la, lb, 1);
|
|
51
|
+
return Math.max(0, 1 - Math.abs(la - lb) / span);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const WEIGHTS = {
|
|
55
|
+
spacingLadder: 0.12,
|
|
56
|
+
radiusLadder: 0.12,
|
|
57
|
+
typeScale: 0.13,
|
|
58
|
+
fontFamilies: 0.09,
|
|
59
|
+
weightLadder: 0.05,
|
|
60
|
+
motionDurations: 0.06,
|
|
61
|
+
easingVocab: 0.04,
|
|
62
|
+
animatedShare: 0.03,
|
|
63
|
+
elevationLevels: 0.09,
|
|
64
|
+
centeredRatio: 0.07,
|
|
65
|
+
tokenCoverage: 0.07,
|
|
66
|
+
paddingWeight: 0.07,
|
|
67
|
+
// Interaction states: hover-response and focus-visible coverage. .03 each, taken from
|
|
68
|
+
// spacingLadder/radiusLadder (.15 -> .12) so the table still sums to 1.00.
|
|
69
|
+
hoverCoverage: 0.03,
|
|
70
|
+
focusCoverage: 0.03,
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
type Component = keyof typeof WEIGHTS;
|
|
74
|
+
|
|
75
|
+
function componentScores(a: Invariants, b: Invariants): Record<Component, number> {
|
|
76
|
+
return {
|
|
77
|
+
spacingLadder: jaccard(a.spacingLadder, b.spacingLadder),
|
|
78
|
+
radiusLadder: jaccard(a.radiusLadder, b.radiusLadder),
|
|
79
|
+
typeScale: jaccard(a.typeScale, b.typeScale),
|
|
80
|
+
fontFamilies: jaccardStrings(a.fontFamilies, b.fontFamilies),
|
|
81
|
+
weightLadder: jaccard(a.weightLadder, b.weightLadder),
|
|
82
|
+
motionDurations: jaccard(a.motionDurations, b.motionDurations),
|
|
83
|
+
easingVocab: jaccardStrings(a.easingVocab, b.easingVocab),
|
|
84
|
+
animatedShare: 1 - Math.abs(a.animatedShare - b.animatedShare),
|
|
85
|
+
elevationLevels: ratioDiff(a.elevationLevels, b.elevationLevels),
|
|
86
|
+
centeredRatio: 1 - Math.abs(a.centeredRatio - b.centeredRatio),
|
|
87
|
+
tokenCoverage: 1 - Math.abs(a.tokenCoverage - b.tokenCoverage),
|
|
88
|
+
paddingWeight: logRatio(a.paddingWeight, b.paddingWeight),
|
|
89
|
+
hoverCoverage: 1 - Math.abs(a.hoverCoverage - b.hoverCoverage),
|
|
90
|
+
focusCoverage: 1 - Math.abs(a.focusCoverage - b.focusCoverage),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Components backed by a ladder/vocabulary array, as opposed to a scalar. Only these can
|
|
96
|
+
* be "unmeasured" on one side — a scalar always has a value (0 is a real measurement, an
|
|
97
|
+
* empty ladder is the absence of one).
|
|
98
|
+
*/
|
|
99
|
+
const ARRAY_COMPONENTS = [
|
|
100
|
+
'spacingLadder', 'radiusLadder', 'typeScale', 'fontFamilies', 'weightLadder',
|
|
101
|
+
'motionDurations', 'easingVocab',
|
|
102
|
+
] as const satisfies readonly Component[];
|
|
103
|
+
|
|
104
|
+
function arrayFor(inv: Invariants, key: (typeof ARRAY_COMPONENTS)[number]): readonly (number | string)[] {
|
|
105
|
+
return inv[key];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Scalar components where 0 is ambiguous: a page where every interactive element truly
|
|
110
|
+
* fails to respond scores identically to a page that was never probed at all (an
|
|
111
|
+
* unmeasured pre-interaction reference, or a probe failure). Both read as `hoverCoverage:
|
|
112
|
+
* 0` / `focusCoverage: 0`. Scoring a real page against an old reference with `1 -
|
|
113
|
+
* |a-b|` would then punish it for a coverage gap that was never actually measured on the
|
|
114
|
+
* reference's side — so, like the array components above, exclude the component (and
|
|
115
|
+
* renormalise) whenever either side reads exactly 0.
|
|
116
|
+
*/
|
|
117
|
+
const ZERO_EXCLUDABLE_COMPONENTS = ['hoverCoverage', 'focusCoverage'] as const satisfies readonly Component[];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* F1: an empty ladder is not "measured and maximally different" — it is unmeasured. A
|
|
121
|
+
* pre-typography reference defaults typeScale/fontFamilies/weightLadder/motionDurations/
|
|
122
|
+
* easingVocab to `[]` (core/ref/store.ts withInvariantDefaults). Scoring that against a
|
|
123
|
+
* populated ladder with jaccard hits the empty-vs-populated branch (0, not the
|
|
124
|
+
* both-empty-is-1 branch), which the geometric mean then floors to EPSILON and drags the
|
|
125
|
+
* whole product toward zero — collapsing an old reference's similarity to its own,
|
|
126
|
+
* identical page to ~0.08–0.15. Excluding the component when either side is empty (and
|
|
127
|
+
* renormalising the remaining weights) treats "unmeasured" as neutral instead of as a
|
|
128
|
+
* maximal mismatch. Both-empty is excluded too — no longer a reward for two designs that
|
|
129
|
+
* simply never used the feature, since there is nothing there to have agreed on.
|
|
130
|
+
*/
|
|
131
|
+
function excludedComponents(a: Invariants, b: Invariants): ReadonlySet<Component> {
|
|
132
|
+
const excluded = new Set<Component>();
|
|
133
|
+
for (const key of ARRAY_COMPONENTS) {
|
|
134
|
+
if (arrayFor(a, key).length === 0 || arrayFor(b, key).length === 0) excluded.add(key);
|
|
135
|
+
}
|
|
136
|
+
for (const key of ZERO_EXCLUDABLE_COMPONENTS) {
|
|
137
|
+
if (a[key] === 0 || b[key] === 0) excluded.add(key);
|
|
138
|
+
}
|
|
139
|
+
return excluded;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Weighted GEOMETRIC mean, not arithmetic.
|
|
144
|
+
*
|
|
145
|
+
* An arithmetic mean lets agreement on the cheap components hide disagreement on the
|
|
146
|
+
* expensive ones. Two pages with nothing in common but a shared centred-text ratio, token
|
|
147
|
+
* coverage and padding weight already floor at 0.50 — over half the copying threshold —
|
|
148
|
+
* before their spacing and radius ladders are even consulted. Measured: Linear's thirteen
|
|
149
|
+
* spacing rungs against an ordinary five-rung 8pt scale scored 0.632, flagged as a copy,
|
|
150
|
+
* on ladder scores of 0.385 and 0.143.
|
|
151
|
+
*
|
|
152
|
+
* A design's identity lives in its ladders. Under a geometric mean a near-zero on either
|
|
153
|
+
* one drags the product down and no amount of incidental agreement rescues it.
|
|
154
|
+
*/
|
|
155
|
+
export function similarity(a: Invariants, b: Invariants): number {
|
|
156
|
+
const scores = componentScores(a, b);
|
|
157
|
+
const excluded = excludedComponents(a, b);
|
|
158
|
+
const keys = (Object.keys(WEIGHTS) as Component[]).filter((key) => !excluded.has(key));
|
|
159
|
+
|
|
160
|
+
// F1: if every array component is unmeasured on one side or the other, nothing is left
|
|
161
|
+
// to compare — two totally unmeasured things cannot be distinguished, so treat them as
|
|
162
|
+
// identical rather than maximally different (the old empty-scores-0 behaviour).
|
|
163
|
+
if (keys.length === 0) return 1;
|
|
164
|
+
|
|
165
|
+
// Renormalise: the excluded components' weight is redistributed proportionally over
|
|
166
|
+
// what remains, so the included weights still sum to 1.
|
|
167
|
+
const weightSum = keys.reduce((sum, key) => sum + WEIGHTS[key], 0);
|
|
168
|
+
|
|
169
|
+
// log(0) is -Infinity, which would make every dissimilar pair score exactly 0 and lose
|
|
170
|
+
// all ordering among them. The floor keeps them comparable.
|
|
171
|
+
const EPSILON = 1e-3;
|
|
172
|
+
let logSum = 0;
|
|
173
|
+
for (const key of keys) logSum += (WEIGHTS[key] / weightSum) * Math.log(Math.max(scores[key], EPSILON));
|
|
174
|
+
|
|
175
|
+
return Math.min(1, Math.max(0, Math.exp(logSum)));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface KinshipPair {
|
|
179
|
+
/** Source URL of the first reference. */
|
|
180
|
+
a: string;
|
|
181
|
+
/** Source URL of the second reference. */
|
|
182
|
+
b: string;
|
|
183
|
+
similarity: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Pairwise similarity over the board. Returns the top-N pairs whose similarity meets or
|
|
188
|
+
* exceeds `threshold`, sorted most-similar first. Image references (null invariants) are
|
|
189
|
+
* excluded — pixels have no invariants to compare.
|
|
190
|
+
*
|
|
191
|
+
* A cluster of near-identical references is not breadth: it is the same average measured
|
|
192
|
+
* multiple times. When AI-generated pages dominate a search result set, they tend to
|
|
193
|
+
* cluster here. That is the contamination signal.
|
|
194
|
+
*/
|
|
195
|
+
export function topKinshipPairs(refs: Reference[], threshold = 0.85, topN = 3): KinshipPair[] {
|
|
196
|
+
const measured = refs.filter(
|
|
197
|
+
(r): r is Reference & { invariants: Invariants } => r.invariants !== null,
|
|
198
|
+
);
|
|
199
|
+
const pairs: KinshipPair[] = [];
|
|
200
|
+
for (let i = 0; i < measured.length; i++) {
|
|
201
|
+
for (let j = i + 1; j < measured.length; j++) {
|
|
202
|
+
const sim = similarity(measured[i]!.invariants, measured[j]!.invariants);
|
|
203
|
+
if (sim >= threshold) {
|
|
204
|
+
pairs.push({ a: measured[i]!.source, b: measured[j]!.source, similarity: sim });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return pairs.sort((x, y) => y.similarity - x.similarity).slice(0, topN);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function distances(page: Invariants, refs: Reference[]): RefDistance[] {
|
|
212
|
+
return refs
|
|
213
|
+
.filter((ref): ref is Reference & { invariants: Invariants } => ref.invariants !== null)
|
|
214
|
+
.map((ref) => {
|
|
215
|
+
const scores = componentScores(page, ref.invariants);
|
|
216
|
+
const excluded = excludedComponents(page, ref.invariants);
|
|
217
|
+
// F1: drivers are computed from the included components only — an excluded
|
|
218
|
+
// (unmeasured) component cannot have driven the score.
|
|
219
|
+
const drivers = (Object.keys(WEIGHTS) as Component[])
|
|
220
|
+
.filter((key) => !excluded.has(key) && scores[key] >= 0.8)
|
|
221
|
+
.sort((x, y) => scores[y] - scores[x]);
|
|
222
|
+
return { reference: ref.source, similarity: similarity(page, ref.invariants), drivers };
|
|
223
|
+
})
|
|
224
|
+
.sort((a, b) => b.similarity - a.similarity);
|
|
225
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { Ir, Invariants } from '../types.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keeps the values that carry 90% of the uses, sorted ascending. Zero is dropped first —
|
|
5
|
+
* on a real page `padding: 0` is the majority of every spacing value and would otherwise
|
|
6
|
+
* dominate the ladder. Linear declares 11 distinct radii; the 90% cut leaves 7, which is
|
|
7
|
+
* what a designer would actually name.
|
|
8
|
+
*/
|
|
9
|
+
export function ladder(histogram: Record<string, number>): number[] {
|
|
10
|
+
const entries = Object.entries(histogram).filter(([key]) => key !== '0');
|
|
11
|
+
const total = entries.reduce((sum, [, count]) => sum + count, 0);
|
|
12
|
+
if (total === 0) return [];
|
|
13
|
+
|
|
14
|
+
const sorted = [...entries].sort(([, a], [, b]) => b - a);
|
|
15
|
+
const kept: number[] = [];
|
|
16
|
+
let covered = 0;
|
|
17
|
+
for (const [key, count] of sorted) {
|
|
18
|
+
kept.push(Number(key));
|
|
19
|
+
covered += count;
|
|
20
|
+
if (covered / total >= 0.9) break;
|
|
21
|
+
}
|
|
22
|
+
return kept.sort((a, b) => a - b);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Splits a comma-separated shadow list on commas that are not inside parentheses. */
|
|
26
|
+
function splitLayers(shadow: string): string[] {
|
|
27
|
+
const layers: string[] = [];
|
|
28
|
+
let depth = 0;
|
|
29
|
+
let current = '';
|
|
30
|
+
for (const ch of shadow) {
|
|
31
|
+
if (ch === '(') depth += 1;
|
|
32
|
+
else if (ch === ')') depth -= 1;
|
|
33
|
+
if (ch === ',' && depth === 0) {
|
|
34
|
+
layers.push(current);
|
|
35
|
+
current = '';
|
|
36
|
+
} else {
|
|
37
|
+
current += ch;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (current.trim() !== '') layers.push(current);
|
|
41
|
+
return layers;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when a single shadow layer has zero x-offset, zero y-offset and zero blur — a
|
|
46
|
+
* border drawn as a box-shadow. Colour and `inset` are stripped first so their digits
|
|
47
|
+
* (e.g. the `0.2` in `rgba(0, 0, 0, 0.2)`) never get mistaken for offsets.
|
|
48
|
+
*/
|
|
49
|
+
function isHairlineLayer(layer: string): boolean {
|
|
50
|
+
const withoutColor = layer.replace(/\([^)]*\)/g, ' ');
|
|
51
|
+
const withoutInset = withoutColor.replace(/\binset\b/g, ' ');
|
|
52
|
+
const lengths = withoutInset.trim().split(/\s+/).filter((t) => t.length > 0);
|
|
53
|
+
const [x, y, blur] = lengths;
|
|
54
|
+
if (x === undefined || y === undefined || blur === undefined) return false;
|
|
55
|
+
const toNum = (v: string): number => parseFloat(v) || 0;
|
|
56
|
+
return toNum(x) === 0 && toNum(y) === 0 && toNum(blur) === 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isHairline(shadow: string): boolean {
|
|
60
|
+
const layers = splitLayers(shadow).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
61
|
+
if (layers.length === 0) return false;
|
|
62
|
+
return layers.every(isHairlineLayer);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const round = (value: number, dp: number): number => {
|
|
66
|
+
const factor = 10 ** dp;
|
|
67
|
+
return Math.round(value * factor) / factor;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function extractInvariants(ir: Ir): Invariants {
|
|
71
|
+
const spacingLadder = ladder(ir.stats.spacingHistogram);
|
|
72
|
+
const radiusLadder = ladder(ir.stats.radiusHistogram);
|
|
73
|
+
|
|
74
|
+
const elevationLevels = Object.keys(ir.stats.shadowHistogram)
|
|
75
|
+
.filter((key) => key !== 'none' && !isHairline(key)).length;
|
|
76
|
+
|
|
77
|
+
const centeredRatio = ir.stats.centeredTextRatio;
|
|
78
|
+
|
|
79
|
+
const tokenableNodes = ir.nodes.filter((n) => n.fill != null || n.radius != null);
|
|
80
|
+
const tokenCoverage = tokenableNodes.length === 0
|
|
81
|
+
? 1
|
|
82
|
+
: round(tokenableNodes.reduce((sum, n) => sum + n.computed.tokenCoverage, 0) / tokenableNodes.length, 4);
|
|
83
|
+
|
|
84
|
+
const paddedNodes = ir.nodes.filter((n) => n.layout?.padding != null);
|
|
85
|
+
const paddingWeight = paddedNodes.length === 0
|
|
86
|
+
? 0
|
|
87
|
+
: round(
|
|
88
|
+
paddedNodes.reduce((sum, n) => sum + n.layout!.padding.reduce((s, v) => s + v, 0), 0) / paddedNodes.length,
|
|
89
|
+
2,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Typography: histograms over text-bearing nodes, ladder-cut the same way spacing and
|
|
93
|
+
// radius are — a font-size used once in a footer should not dilute the scale.
|
|
94
|
+
const fontSizeHistogram: Record<string, number> = {};
|
|
95
|
+
const fontWeightHistogram: Record<string, number> = {};
|
|
96
|
+
const fontFamilies = new Set<string>();
|
|
97
|
+
for (const n of ir.nodes) {
|
|
98
|
+
if (n.fontSize != null) fontSizeHistogram[n.fontSize] = (fontSizeHistogram[n.fontSize] ?? 0) + 1;
|
|
99
|
+
if (n.fontWeight != null) fontWeightHistogram[n.fontWeight] = (fontWeightHistogram[n.fontWeight] ?? 0) + 1;
|
|
100
|
+
if (n.fontFamily) fontFamilies.add(n.fontFamily);
|
|
101
|
+
}
|
|
102
|
+
const typeScale = ladder(fontSizeHistogram);
|
|
103
|
+
const weightLadder = ladder(fontWeightHistogram);
|
|
104
|
+
|
|
105
|
+
// Motion: durations ladder-cut the same way; easings and families are the full sorted
|
|
106
|
+
// vocabulary — a design either uses `cubic-bezier(0.2,0,0,1)` or it does not, there is no
|
|
107
|
+
// 90th-percentile cut for a vocabulary.
|
|
108
|
+
const durationHistogram: Record<string, number> = {};
|
|
109
|
+
const easingVocabSet = new Set<string>();
|
|
110
|
+
let animatedNodes = 0;
|
|
111
|
+
for (const n of ir.nodes) {
|
|
112
|
+
if (!n.motion) continue;
|
|
113
|
+
animatedNodes += 1;
|
|
114
|
+
for (const d of n.motion.durations) durationHistogram[d] = (durationHistogram[d] ?? 0) + 1;
|
|
115
|
+
for (const e of n.motion.easings) easingVocabSet.add(e);
|
|
116
|
+
}
|
|
117
|
+
const motionDurations = ladder(durationHistogram);
|
|
118
|
+
const animatedShare = ir.nodes.length === 0 ? 0 : round(animatedNodes / ir.nodes.length, 4);
|
|
119
|
+
|
|
120
|
+
// Interaction: read defensively. The probe (core/render/index.ts) attaches
|
|
121
|
+
// ir.meta.interaction on success and null on failure; a hand-built or pre-probe IR
|
|
122
|
+
// (fixtures, old references) has no meta.interaction at all.
|
|
123
|
+
const interaction = ir.meta?.['interaction'] as
|
|
124
|
+
| { probed: number; hoverResponsive: number; tabStops: number; focusVisible: number }
|
|
125
|
+
| null
|
|
126
|
+
| undefined;
|
|
127
|
+
const hoverCoverage = interaction && interaction.probed > 0
|
|
128
|
+
? round(interaction.hoverResponsive / interaction.probed, 2)
|
|
129
|
+
: 0;
|
|
130
|
+
const focusCoverage = interaction && interaction.tabStops > 0
|
|
131
|
+
? round(interaction.focusVisible / interaction.tabStops, 2)
|
|
132
|
+
: 0;
|
|
133
|
+
|
|
134
|
+
// Motion probe: attached by probeMotion() in core/render/index.ts. Absent on IRs
|
|
135
|
+
// captured before the probe existed — read defensively to keep old refs loading cleanly.
|
|
136
|
+
// PROBE LIMIT: rAF-driven libs (GSAP) are not visible to getAnimations(); their absence
|
|
137
|
+
// from animatedProperties is a correct measurement, not a missing field.
|
|
138
|
+
const motionProbe = ir.meta?.['motion'] as
|
|
139
|
+
| { animatedProperties?: string[]; hasReducedMotion?: boolean; scrollChoreography?: Array<{ step: number; fired: number; entered: number }> }
|
|
140
|
+
| null
|
|
141
|
+
| undefined;
|
|
142
|
+
const animatedProperties = motionProbe?.animatedProperties
|
|
143
|
+
? [...motionProbe.animatedProperties].sort()
|
|
144
|
+
: [];
|
|
145
|
+
const hasReducedMotion = motionProbe?.hasReducedMotion ?? false;
|
|
146
|
+
const scrollChoreography = motionProbe?.scrollChoreography ?? [];
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
spacingLadder, radiusLadder, elevationLevels, centeredRatio, tokenCoverage, paddingWeight,
|
|
150
|
+
typeScale,
|
|
151
|
+
fontFamilies: Array.from(fontFamilies).sort(),
|
|
152
|
+
weightLadder,
|
|
153
|
+
motionDurations,
|
|
154
|
+
easingVocab: Array.from(easingVocabSet).sort(),
|
|
155
|
+
animatedShare,
|
|
156
|
+
hoverCoverage,
|
|
157
|
+
focusCoverage,
|
|
158
|
+
animatedProperties,
|
|
159
|
+
hasReducedMotion,
|
|
160
|
+
scrollChoreography,
|
|
161
|
+
};
|
|
162
|
+
}
|