@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,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.
|