@3xhaust/oh-my-design 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +282 -0
  3. package/README.md +282 -0
  4. package/adapters/build.ts +184 -0
  5. package/adapters/claude.ts +111 -0
  6. package/adapters/codex.ts +68 -0
  7. package/adapters/tokens.ts +21 -0
  8. package/adapters/tool-map.json +7 -0
  9. package/bin/omd-install.ts +76 -0
  10. package/bin/omd.ts +1774 -0
  11. package/core/asset-sourcing/index.ts +187 -0
  12. package/core/coach/index.ts +98 -0
  13. package/core/composition/app-shell-workbench.md +168 -0
  14. package/core/composition/asymmetric-diagonal-grid.md +178 -0
  15. package/core/composition/bento-grid.md +223 -0
  16. package/core/composition/editorial-index-labels.md +173 -0
  17. package/core/composition/form-wizard-stepper.md +132 -0
  18. package/core/composition/master-detail-flow.md +129 -0
  19. package/core/composition/section-inversion.md +182 -0
  20. package/core/composition/sidebar-margin-annotation.md +184 -0
  21. package/core/composition/split-screen-hero.md +224 -0
  22. package/core/composition/sticky-sidebar-scroll.md +219 -0
  23. package/core/composition/typographic-hero.md +100 -0
  24. package/core/composition-contract/index.ts +233 -0
  25. package/core/composition-contract/visual-richness.ts +101 -0
  26. package/core/config/index.ts +27 -0
  27. package/core/copy/index.ts +296 -0
  28. package/core/craft/finish-pass.md +356 -0
  29. package/core/craft/index.ts +33 -0
  30. package/core/design/index.ts +581 -0
  31. package/core/design/interaction-states.ts +189 -0
  32. package/core/eval-harness/index.ts +225 -0
  33. package/core/evidence/final.ts +444 -0
  34. package/core/evidence/task.ts +459 -0
  35. package/core/figma/client.ts +312 -0
  36. package/core/figma/diff.ts +261 -0
  37. package/core/figma/responsive.ts +310 -0
  38. package/core/figma/system.ts +466 -0
  39. package/core/figma/types.ts +185 -0
  40. package/core/frame/check-ux.ts +180 -0
  41. package/core/frame/index.ts +39 -0
  42. package/core/frame/write.ts +168 -0
  43. package/core/graphics/css-illustration-primitives.md +189 -0
  44. package/core/graphics/duotone-image-presets.md +173 -0
  45. package/core/graphics/gradient-mesh.md +178 -0
  46. package/core/graphics/noise-grain-texture.md +150 -0
  47. package/core/graphics/placeholder-policy.md +185 -0
  48. package/core/graphics/svg-geometric-patterns.md +145 -0
  49. package/core/history/index.ts +39 -0
  50. package/core/install/detect.ts +32 -0
  51. package/core/install/install.ts +321 -0
  52. package/core/install/patch-claude.ts +109 -0
  53. package/core/install/patch-codex.ts +82 -0
  54. package/core/interaction/index.ts +101 -0
  55. package/core/interaction/recipes/signature-lighting.md +203 -0
  56. package/core/ir/dom.ts +352 -0
  57. package/core/ir/normalize.ts +177 -0
  58. package/core/motion/easing.md +151 -0
  59. package/core/motion/energy.ts +188 -0
  60. package/core/motion/recipes/image-hover-distortion.md +198 -0
  61. package/core/motion/recipes/magnetic-hover.md +229 -0
  62. package/core/motion/recipes/marquee.md +202 -0
  63. package/core/motion/recipes/number-counter.md +242 -0
  64. package/core/motion/recipes/page-loader.md +240 -0
  65. package/core/motion/recipes/parallax.md +233 -0
  66. package/core/motion/recipes/scroll-reveal.md +210 -0
  67. package/core/motion/recipes/section-color-inversion.md +217 -0
  68. package/core/motion/recipes/split-text-entrance.md +222 -0
  69. package/core/motion/recipes/stagger-orchestrator.md +227 -0
  70. package/core/motion/recipes/sticky-scene-transition.md +246 -0
  71. package/core/motion/recipes/view-transitions.md +239 -0
  72. package/core/probe/index.ts +186 -0
  73. package/core/probe/schema.json +46 -0
  74. package/core/protocol/composition-contract.md +192 -0
  75. package/core/protocol/copy-deck.md +96 -0
  76. package/core/protocol/human-design-loop.md +447 -0
  77. package/core/protocol/slop-review.md +71 -0
  78. package/core/ref/batch.ts +94 -0
  79. package/core/ref/blueprint.ts +199 -0
  80. package/core/ref/distance.ts +225 -0
  81. package/core/ref/invariants.ts +162 -0
  82. package/core/ref/signal.ts +37 -0
  83. package/core/ref/store.ts +122 -0
  84. package/core/render/index.ts +607 -0
  85. package/core/rules/attribution.ts +159 -0
  86. package/core/rules/builtin/contrast.yaml +8 -0
  87. package/core/rules/builtin/focus.yaml +8 -0
  88. package/core/rules/builtin/hit-area.yaml +8 -0
  89. package/core/rules/builtin/ko.yaml +6 -0
  90. package/core/rules/builtin/motion.yaml +80 -0
  91. package/core/rules/builtin/slop.yaml +365 -0
  92. package/core/rules/builtin/spacing.yaml +9 -0
  93. package/core/rules/builtin/system.yaml +42 -0
  94. package/core/rules/builtin/token.yaml +8 -0
  95. package/core/rules/builtin/ux.yaml +125 -0
  96. package/core/rules/engine.ts +88 -0
  97. package/core/rules/leakage.ts +72 -0
  98. package/core/rules/motion-spec.ts +204 -0
  99. package/core/site/index.ts +100 -0
  100. package/core/slop/index.ts +427 -0
  101. package/core/slop/text-slop.ts +220 -0
  102. package/core/source-seal/index.ts +174 -0
  103. package/core/stack/index.ts +53 -0
  104. package/core/target/index.ts +183 -0
  105. package/core/theory/color.md +285 -0
  106. package/core/theory/components.md +272 -0
  107. package/core/theory/craft.md +270 -0
  108. package/core/theory/expressive.md +213 -0
  109. package/core/theory/imagegen.md +105 -0
  110. package/core/theory/layout.md +159 -0
  111. package/core/theory/motion.md +283 -0
  112. package/core/theory/typography.md +134 -0
  113. package/core/theory/ux.md +580 -0
  114. package/core/theory/voice.md +595 -0
  115. package/core/types.ts +684 -0
  116. package/package.json +40 -0
  117. package/src/agents/composer.agent.yaml +174 -0
  118. package/src/agents/eye.agent.yaml +208 -0
  119. package/src/agents/framer.agent.yaml +52 -0
  120. package/src/agents/glance.agent.yaml +19 -0
  121. package/src/agents/hand.agent.yaml +200 -0
  122. package/src/agents/scout.agent.yaml +71 -0
  123. package/src/agents/sketch.agent.yaml +64 -0
  124. package/src/agents/typesetter.agent.yaml +52 -0
  125. package/src/agents/writer.agent.yaml +63 -0
  126. package/src/skills/omd-coach/SKILL.md +40 -0
  127. package/src/skills/omd-critique/SKILL.md +77 -0
  128. package/src/skills/omd-figma/SKILL.md +256 -0
  129. package/src/skills/omd-humanize/SKILL.md +160 -0
  130. package/src/skills/omd-scout/SKILL.md +77 -0
  131. package/src/skills/omd-ultradesign/SKILL.md +521 -0
@@ -0,0 +1,82 @@
1
+ const MARKER_BEGIN = '# ── OMD BEGIN (do not edit; managed by oh-my-design) ──';
2
+ const MARKER_END = '# ── OMD END ──';
3
+ const FEATURE_KEYS = ['hooks', 'plugins', 'plugin_hooks', 'multi_agent'] as const;
4
+ const TAG = ' # OMD';
5
+
6
+ const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+
8
+ export function patchConfigToml(text: string, opts: { agents?: string[] } = {}): string {
9
+ const agents = opts.agents ?? [];
10
+
11
+ // Rewrite rather than bail out when already patched: an upgrade that adds an agent must
12
+ // register it, and bailing would leave it silently missing.
13
+ const lines = unpatchConfigToml(text).split('\n');
14
+
15
+ const featuresIdx = lines.findIndex((l) => l.trim() === '[features]');
16
+ let inlineFeatures = '';
17
+
18
+ if (featuresIdx === -1) {
19
+ inlineFeatures = `[features]\n${FEATURE_KEYS.map((k) => `${k} = true`).join('\n')}\n\n`;
20
+ } else {
21
+ // TOML forbids redeclaring [features], so missing keys go into the existing table and
22
+ // each added line is tagged so unpatch can strip exactly those.
23
+ let end = lines.length;
24
+ for (let i = featuresIdx + 1; i < lines.length; i++) {
25
+ if (lines[i]!.startsWith('[')) {
26
+ end = i;
27
+ break;
28
+ }
29
+ }
30
+ const existing = new Set<string>();
31
+ for (let i = featuresIdx + 1; i < end; i++) {
32
+ const m = /^(\w+)\s*=/.exec(lines[i]!);
33
+ if (m?.[1]) existing.add(m[1]);
34
+ }
35
+ const missing = FEATURE_KEYS.filter((k) => !existing.has(k));
36
+ if (missing.length) lines.splice(featuresIdx + 1, 0, ...missing.map((k) => `${k} = true${TAG}`));
37
+ }
38
+
39
+ const agentBlock = agents
40
+ .map((name) => `[agents.${name}]\nconfig_file = "./agents/${name}.toml"`)
41
+ .join('\n\n');
42
+
43
+ return `${lines.join('\n')}\n${MARKER_BEGIN}\n${inlineFeatures}${agentBlock}\n${MARKER_END}\n`;
44
+ }
45
+
46
+ export function unpatchConfigToml(text: string): string {
47
+ if (!text.includes(MARKER_BEGIN)) return text;
48
+ const block = new RegExp(`\\n${escapeRegExp(MARKER_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n`);
49
+ return text.replace(block, '').split('\n').filter((l) => !l.endsWith(TAG)).join('\n');
50
+ }
51
+
52
+ /**
53
+ * Codex trusts a hook file by the sha256 of its bytes rather than by a version, recording
54
+ * `[hooks.state."<key>"] trusted_hash = "sha256:..."` in config.toml. The key format below
55
+ * was read off a real install:
56
+ * "omo@sisyphuslabs:hooks/session-start-loading-project-rules.json:session_start:0:0"
57
+ * i.e. `<plugin>@<marketplace>:<relative hook file>:<snake_case event>:<groupIndex>:<hookIndex>`.
58
+ * This has NOT been independently confirmed against a real Codex session for this project's
59
+ * plugin id — `doctor` must report hook trust as unverified, never as a pass.
60
+ */
61
+ export function trustedHashKey(
62
+ pluginId: string,
63
+ hookFile: string,
64
+ event: string,
65
+ groupIndex = 0,
66
+ hookIndex = 0,
67
+ ): string {
68
+ const snakeEvent = event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
69
+ return `${pluginId}:${hookFile}:${snakeEvent}:${groupIndex}:${hookIndex}`;
70
+ }
71
+
72
+ /** Writes (or replaces) trusted_hash entries. Caller supplies the sha256 of each hook file. */
73
+ export function patchHookTrust(text: string, entries: { key: string; sha256: string }[]): string {
74
+ let out = text;
75
+ for (const { key, sha256 } of entries) {
76
+ const header = `[hooks.state."${key}"]`;
77
+ const block = `${header}\ntrusted_hash = "sha256:${sha256}"\n`;
78
+ const re = new RegExp(`\\[hooks\\.state\\."${escapeRegExp(key)}"\\][^\\[]*`);
79
+ out = re.test(out) ? out.replace(re, block) : `${out.endsWith('\n') ? out : `${out}\n`}\n${block}`;
80
+ }
81
+ return out;
82
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Signature-interaction ideation, mechanism 1 (scout signal board) and mechanism 2
3
+ * (ideation gate). Pure functions only — no I/O, no CLI wiring.
4
+ *
5
+ * The signal board here is a SEPARATE recommendation input from core/ref/distance.ts.
6
+ * It does NOT read, derive from, or feed into that module's fixed WEIGHTS or Invariants
7
+ * shape, and it must never be wired to alter the ≤0.6 kinship/near-duplicate gate meaning
8
+ * there. Board entries only ever carry named measurements (numbers) and named principles
9
+ * (strings) — screenshots or other raw imagery are refused, per the measurement/principle
10
+ * grounding rule (no visual-mimicry ingestion).
11
+ *
12
+ * The ideation gate below governs whether autonomously generated interaction ideas may
13
+ * proceed; it does not itself perform rendering or ideation. The actual render/ideation
14
+ * runtime is a Phase 2 dependency and lives outside this module.
15
+ */
16
+
17
+ export interface InteractionSignalInput {
18
+ source: string;
19
+ measurements: Record<string, number>;
20
+ principles: string[];
21
+ /** Never accepted: presence triggers refusal. Typed as `never` to flag misuse at compile time. */
22
+ screenshot?: never;
23
+ }
24
+
25
+ export interface InteractionSignalEntry {
26
+ id: string;
27
+ source: string;
28
+ measurements: Record<string, number>;
29
+ principles: string[];
30
+ kind: 'interaction-signal';
31
+ }
32
+
33
+ export interface InteractionSignalViolation {
34
+ ok: false;
35
+ reason: string;
36
+ }
37
+
38
+ export type InteractionSignalResult = { ok: true; entry: InteractionSignalEntry } | InteractionSignalViolation;
39
+
40
+ function signalId(source: string, measurements: Record<string, number>, principles: string[]): string {
41
+ const parts = [source, ...Object.keys(measurements).sort(), ...principles.slice().sort()];
42
+ return parts.join('|');
43
+ }
44
+
45
+ /**
46
+ * Builds a scout signal board entry from named measurements/principles. Rejects any input
47
+ * that carries a `screenshot` (or other raw-image) field — the board records interaction
48
+ * measurements and principles only, never source pixels.
49
+ */
50
+ export function ingestInteractionSignal(inputs: InteractionSignalInput): InteractionSignalResult {
51
+ if (Object.prototype.hasOwnProperty.call(inputs, 'screenshot')) {
52
+ return { ok: false, reason: 'screenshot rejected: signal board accepts measurements/principles only, no raw imagery' };
53
+ }
54
+ if (!inputs.source || inputs.source.trim() === '') {
55
+ return { ok: false, reason: 'source is required' };
56
+ }
57
+ if (Object.keys(inputs.measurements).length === 0 && inputs.principles.length === 0) {
58
+ return { ok: false, reason: 'at least one measurement or principle is required' };
59
+ }
60
+ return {
61
+ ok: true,
62
+ entry: {
63
+ id: signalId(inputs.source, inputs.measurements, inputs.principles),
64
+ source: inputs.source,
65
+ measurements: { ...inputs.measurements },
66
+ principles: [...inputs.principles],
67
+ kind: 'interaction-signal',
68
+ },
69
+ };
70
+ }
71
+
72
+ export interface IdeationGateInputs {
73
+ registerFit: boolean;
74
+ perfBudgetDeclared: boolean;
75
+ slopClean: boolean;
76
+ handPrecedence: boolean;
77
+ semanticFallbackPresent: boolean;
78
+ }
79
+
80
+ export interface IdeationGateResult {
81
+ permitted: boolean;
82
+ reasons: string[];
83
+ }
84
+
85
+ const GATE_CHECKS: ReadonlyArray<{ key: keyof IdeationGateInputs; reason: string }> = [
86
+ { key: 'registerFit', reason: 'register-fit not satisfied' },
87
+ { key: 'perfBudgetDeclared', reason: 'performance budget not declared' },
88
+ { key: 'slopClean', reason: 'slop check failed' },
89
+ { key: 'handPrecedence', reason: 'hand precedence not honored' },
90
+ { key: 'semanticFallbackPresent', reason: 'non-canvas semantic fallback missing' },
91
+ ];
92
+
93
+ /**
94
+ * Autonomous interaction ideation is subordinate to register-fit, performance budget,
95
+ * slop, hand precedence, and non-canvas semantic fallback gates. Ideation is permitted
96
+ * only when every gate is satisfied; autonomy never bypasses a gate.
97
+ */
98
+ export function evaluateIdeationGate(inputs: IdeationGateInputs): IdeationGateResult {
99
+ const reasons = GATE_CHECKS.filter(({ key }) => !inputs[key]).map(({ reason }) => reason);
100
+ return { permitted: reasons.length === 0, reasons };
101
+ }
@@ -0,0 +1,203 @@
1
+ # Signature lighting (mouse-follow spotlight / god-ray)
2
+
3
+ A signature-lighting interaction ties a soft light source — a spotlight or a god-ray
4
+ volume — to the pointer, so the surface reads as lit from where the visitor is looking.
5
+ The pointer becomes the light. This is a single-slot signature moment: it earns the one
6
+ signature-moment allowance for a page and must not be combined with a second competing
7
+ signature interaction on the same surface.
8
+
9
+ ## When it earns its place / When it does not
10
+
11
+ Condition: a showpiece or confident-register hero, section divider, or dark-canvas
12
+ surface where a light-driven read reinforces the product's own mechanism (optics,
13
+ depth, focus, discovery) and no other signature moment is already claimed. The
14
+ spotlight/god-ray works best over a dark or low-chroma ground, where the lit region
15
+ reads as a genuine luminance shift rather than a colour overlay.
16
+
17
+ Condition against: quiet-register or dense utility surfaces where a moving light draws
18
+ attention away from task completion; light backgrounds, where a `radial-gradient`
19
+ spotlight reads as a grey smudge instead of light; any surface that has already been
20
+ assigned a different signature moment (magnetic hover, parallax, etc.) — do not stack
21
+ two signature interactions on one page. Also: touch-primary contexts, where there is no
22
+ pointer to follow (see Reduced-motion variant for the fallback, which doubles as the
23
+ no-pointer fallback).
24
+
25
+ The default implementation lane is CSS. It requires no library, degrades safely, and
26
+ is cheap enough to run continuously. Escalating to a WebGL renderer is optional and
27
+ must be justified — see WebGL escalation below.
28
+
29
+ ## Parameters
30
+
31
+ ```css
32
+ :root {
33
+ /* Pointer position in the lit surface's local coordinate space, updated on
34
+ pointermove. Falls back to the element's centre before first movement. */
35
+ --spot-x: 50%;
36
+ --spot-y: 40%;
37
+
38
+ /* Radius of the lit region. Larger values read as ambient god-ray light;
39
+ smaller values read as a tight spotlight/torch. */
40
+ --spot-radius: 45vmax;
41
+
42
+ /* Colour and intensity of the lit region. Keep low-saturation: this is a
43
+ luminance effect, not a colour wash. */
44
+ --spot-color: rgba(255, 255, 255, 0.14);
45
+
46
+ /* How quickly the lit region eases toward the pointer's true position.
47
+ Non-zero lag reads as a physical light with mass; zero lag reads as a
48
+ cursor decoration. */
49
+ --spot-follow-duration: 400ms;
50
+ --spot-follow-ease: var(--ease-out-circ, cubic-bezier(0.08, 0.82, 0.17, 1));
51
+ }
52
+ ```
53
+
54
+ ## Implementation
55
+
56
+ CSS is the default lane: a `radial-gradient` positioned by custom properties that a
57
+ `pointermove` listener updates. No canvas, no library.
58
+
59
+ ```html
60
+ <section class="lit-surface" data-signature-light>
61
+ <div class="lit-surface__content"><!-- real page content --></div>
62
+ </section>
63
+ ```
64
+
65
+ ```css
66
+ .lit-surface {
67
+ position: relative;
68
+ isolation: isolate;
69
+ }
70
+
71
+ .lit-surface::before {
72
+ content: '';
73
+ position: absolute;
74
+ inset: 0;
75
+ z-index: 0;
76
+ pointer-events: none;
77
+ background: radial-gradient(
78
+ circle at var(--spot-x) var(--spot-y),
79
+ var(--spot-color) 0%,
80
+ transparent var(--spot-radius)
81
+ );
82
+ transition: --spot-x var(--spot-follow-duration) var(--spot-follow-ease),
83
+ --spot-y var(--spot-follow-duration) var(--spot-follow-ease);
84
+ }
85
+
86
+ .lit-surface__content {
87
+ position: relative;
88
+ z-index: 1;
89
+ }
90
+ ```
91
+
92
+ ```js
93
+ function attachSignatureLight(surface) {
94
+ function onMove(e) {
95
+ const rect = surface.getBoundingClientRect();
96
+ const x = ((e.clientX - rect.left) / rect.width) * 100;
97
+ const y = ((e.clientY - rect.top) / rect.height) * 100;
98
+ surface.style.setProperty('--spot-x', `${x}%`);
99
+ surface.style.setProperty('--spot-y', `${y}%`);
100
+ }
101
+ surface.addEventListener('pointermove', onMove, { passive: true });
102
+ }
103
+
104
+ if (window.matchMedia('(pointer: fine)').matches) {
105
+ document.querySelectorAll('[data-signature-light]').forEach(attachSignatureLight);
106
+ }
107
+ ```
108
+
109
+ `transition` on a custom property requires `@property` registration for the browser to
110
+ interpolate it as a `<percentage>` instead of snapping; register both custom properties
111
+ once at the stylesheet root:
112
+
113
+ ```css
114
+ @property --spot-x {
115
+ syntax: '<percentage>';
116
+ inherits: true;
117
+ initial-value: 50%;
118
+ }
119
+ @property --spot-y {
120
+ syntax: '<percentage>';
121
+ inherits: true;
122
+ initial-value: 40%;
123
+ }
124
+ ```
125
+
126
+ ## WebGL escalation
127
+
128
+ The CSS lane above is the default and covers the large majority of signature-lighting
129
+ requests: a `radial-gradient` tied to pointer coordinates reads convincingly as a
130
+ spotlight or ambient god-ray without a rendering library. Escalating to a WebGL
131
+ implementation (Three.js) is permitted only when all of the following hold, per hand
132
+ precedence:
133
+
134
+ - an explicit user request for a volumetric/3D lighting treatment, or a greenfield
135
+ concept where the product mechanism itself is 3D and a 2D gradient cannot represent
136
+ it (e.g. true light shafts through geometry, refraction, depth-of-field falloff);
137
+ - a declared performance budget (bundle-size and runtime cost) that the surface can
138
+ actually afford — Three.js is not a default dependency and must be justified per
139
+ page, not assumed;
140
+ - a non-canvas semantic fallback: the underlying content must remain real DOM, so it
141
+ stays selectable, focusable, and readable by assistive technology and search
142
+ indexing with the canvas absent or failed. The canvas is a decorative light layer
143
+ behind real content, never a replacement for it.
144
+
145
+ Reference: kaolti's WebGL/Three.js signature-lighting work (HTML-in-canvas content
146
+ composited with a Three.js volumetric light pass, built with Fable/F#) demonstrates the
147
+ escalation lane at its most literal — light genuinely lives in a 3D scene rather than
148
+ being simulated with a 2D gradient. The author's own retrospective on that work flagged
149
+ the god-ray pass as having too much visual noise for the surface it sat on. That
150
+ self-critique is the concrete basis for the restraint calibration in this recipe:
151
+ escalate to WebGL only when the CSS lane is provably insufficient for the mechanism,
152
+ keep the lit region's opacity and radius conservative even in the WebGL lane, and treat
153
+ "more visible light" as a defect, not a feature, once the signature moment already
154
+ reads. This recipe's default is CSS, and the WebGL lane is not a visual upgrade — it is
155
+ a narrow, gated escalation for volumetric cases the CSS lane cannot represent.
156
+
157
+ ## Reduced-motion variant
158
+
159
+ In reduced-motion context (and on `pointer: coarse` devices with no cursor to follow),
160
+ the lit region is disabled entirely, or replaced with a static, fixed-position ambient
161
+ gradient that does not track any input. No `pointermove` listener is attached; no
162
+ custom-property transition runs.
163
+
164
+ ```css
165
+ @media (prefers-reduced-motion: reduce) {
166
+ .lit-surface::before {
167
+ transition: none !important;
168
+ background: radial-gradient(
169
+ circle at 50% 35%,
170
+ var(--spot-color) 0%,
171
+ transparent var(--spot-radius)
172
+ );
173
+ }
174
+ }
175
+ ```
176
+
177
+ ```js
178
+ const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
179
+ const pointerFine = window.matchMedia('(pointer: fine)').matches;
180
+
181
+ if (pointerFine && !prefersReduced) {
182
+ document.querySelectorAll('[data-signature-light]').forEach(attachSignatureLight);
183
+ }
184
+ ```
185
+
186
+ If a WebGL escalation is in use, the reduced-motion and no-pointer-fine paths must skip
187
+ canvas/renderer initialisation entirely and fall back to the same static CSS gradient —
188
+ never to a frozen WebGL frame, which still carries the renderer's load cost for no
189
+ motion benefit.
190
+
191
+ ## Performance note
192
+
193
+ `radial-gradient` repaint driven by custom-property `transition` is compositor-light
194
+ but not compositor-only — moving the gradient's centre still triggers a paint on
195
+ `::before`, not a transform. Keep the effect scoped to one lit surface per page (the
196
+ one-signature-moment allowance already enforces this) and avoid attaching the
197
+ `pointermove` listener to more than one element.
198
+
199
+ If escalating to WebGL, the performance budget must account for renderer
200
+ initialisation cost, per-frame render cost, and total JS payload added to the page;
201
+ none of this is free, and none of it is justified for a spotlight effect the CSS lane
202
+ already achieves. Lazy-initialise the WebGL renderer only after the surface enters the
203
+ viewport, and tear it down when it leaves.