sheleg-design-skill 0.1.0
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.md +88 -0
- package/bin/cli.js +152 -0
- package/package.json +48 -0
- package/skill/SHELEG_DESIGN.md +643 -0
- package/skill/SKILL.md +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ssheleg
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SHELEG Design — agent skill
|
|
2
|
+
|
|
3
|
+
> A motion + particle interface methodology for building cinematic,
|
|
4
|
+
> scroll-driven landing pages — packaged as an installable agent skill for
|
|
5
|
+
> Cursor and Claude.
|
|
6
|
+
|
|
7
|
+
Install it into any project with one command:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx sheleg-design-skill
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That drops a `SKILL.md` + `SHELEG_DESIGN.md` bundle into your project so your
|
|
14
|
+
coding agent can discover the skill and build sites on its principles.
|
|
15
|
+
|
|
16
|
+
## What is SHELEG Design?
|
|
17
|
+
|
|
18
|
+
A page feels *alive* not from many animations, but from a **single source of
|
|
19
|
+
truth** (scroll position) driving **many cheap, layered responses** that are
|
|
20
|
+
individually quiet and collectively cinematic. One scroll "clock" feeds a WebGL
|
|
21
|
+
particle field, a 2D fallback, attention dimming, parallax, scrubbed
|
|
22
|
+
instruments, and a progress rail — each an independent, degrade-to-calm layer.
|
|
23
|
+
Nothing crossfades; things *redeploy*. The result reads as one precision
|
|
24
|
+
instrument responding to your hand.
|
|
25
|
+
|
|
26
|
+
It was reverse-engineered from a production landing page (a 14-scene particle
|
|
27
|
+
narrative that morphs through formations and culminates in a brand "N" that
|
|
28
|
+
charges and bursts). The skill distills the architecture and the principles so
|
|
29
|
+
an agent can rebuild that level on a new site.
|
|
30
|
+
|
|
31
|
+
### The five principles
|
|
32
|
+
|
|
33
|
+
1. **One clock.** All motion derives from one measured scroll state.
|
|
34
|
+
2. **Read per frame, notify rarely.** Hot consumers read imperatively; only
|
|
35
|
+
coarse changes hit the framework's render path.
|
|
36
|
+
3. **Hold, then redeploy.** Hold a formation ~80% of a section, then morph in a
|
|
37
|
+
short, phase-staggered, arc-curved wave. No crossfades.
|
|
38
|
+
4. **Earned motion.** Scrub is for instruments that narrate state over time;
|
|
39
|
+
entrance motion stays sub-500ms and never gates content.
|
|
40
|
+
5. **Degrade to calm.** Reduced-motion / coarse pointer / no-WebGL collapse to a
|
|
41
|
+
static, fully-legible page.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Auto-detect (.cursor/ or .claude/), default to .cursor/skills/sheleg-design/
|
|
47
|
+
npx sheleg-design-skill
|
|
48
|
+
|
|
49
|
+
# Force a flavor
|
|
50
|
+
npx sheleg-design-skill --cursor
|
|
51
|
+
npx sheleg-design-skill --claude
|
|
52
|
+
|
|
53
|
+
# Custom location
|
|
54
|
+
npx sheleg-design-skill --dir docs/skills/sheleg-design
|
|
55
|
+
|
|
56
|
+
# Overwrite an existing install
|
|
57
|
+
npx sheleg-design-skill --force
|
|
58
|
+
|
|
59
|
+
# Help
|
|
60
|
+
npx sheleg-design-skill --help
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### What gets installed
|
|
64
|
+
|
|
65
|
+
| File | Purpose |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `SKILL.md` | Agent-facing skill: discovery trigger + the principles + how to apply them |
|
|
68
|
+
| `SHELEG_DESIGN.md` | The full reference: architecture, layer-by-layer mechanics with code, the exact morph math, the DOM↔WebGL projection bridge, a build-from-scratch recipe, and the "why it works" |
|
|
69
|
+
|
|
70
|
+
After installing, a Cursor or Claude agent in that project can discover the
|
|
71
|
+
skill and use it when you ask it to build or upgrade a cinematic,
|
|
72
|
+
scroll-driven, particle-backed page.
|
|
73
|
+
|
|
74
|
+
## Stack-agnostic
|
|
75
|
+
|
|
76
|
+
The skill teaches **principles and architecture**, not a fixed dependency set.
|
|
77
|
+
The reference implementation happens to use Next.js + React + three /
|
|
78
|
+
react-three-fiber + GSAP ScrollTrigger + Lenis + Framer Motion, but the method
|
|
79
|
+
applies to any stack that can render to a canvas/WebGL surface and read scroll.
|
|
80
|
+
|
|
81
|
+
## Zero dependencies
|
|
82
|
+
|
|
83
|
+
The installer is a single zero-dependency Node script, so `npx` runs instantly
|
|
84
|
+
with no install step and no supply-chain surface.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT © ssheleg
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* sheleg-design-skill installer.
|
|
6
|
+
*
|
|
7
|
+
* Copies the SHELEG Design skill bundle (SKILL.md + SHELEG_DESIGN.md) into the
|
|
8
|
+
* current project so a Cursor / Claude agent can discover and apply it.
|
|
9
|
+
*
|
|
10
|
+
* Zero dependencies on purpose: it must run instantly via `npx` with no install
|
|
11
|
+
* step and no supply-chain surface.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
|
|
17
|
+
const SKILL_DIR = path.join(__dirname, "..", "skill");
|
|
18
|
+
const SKILL_SLUG = "sheleg-design";
|
|
19
|
+
const FILES = ["SKILL.md", "SHELEG_DESIGN.md"];
|
|
20
|
+
|
|
21
|
+
const pkg = require(path.join(__dirname, "..", "package.json"));
|
|
22
|
+
|
|
23
|
+
const COLORS = {
|
|
24
|
+
reset: "\x1b[0m",
|
|
25
|
+
bold: "\x1b[1m",
|
|
26
|
+
dim: "\x1b[2m",
|
|
27
|
+
blue: "\x1b[38;5;75m",
|
|
28
|
+
green: "\x1b[38;5;42m",
|
|
29
|
+
yellow: "\x1b[38;5;214m",
|
|
30
|
+
};
|
|
31
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
32
|
+
const c = (color, s) => (useColor ? COLORS[color] + s + COLORS.reset : s);
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const opts = {
|
|
36
|
+
target: null, // explicit --dir
|
|
37
|
+
flavor: null, // "cursor" | "claude"
|
|
38
|
+
force: false,
|
|
39
|
+
help: false,
|
|
40
|
+
version: false,
|
|
41
|
+
};
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i];
|
|
44
|
+
if (a === "--help" || a === "-h") opts.help = true;
|
|
45
|
+
else if (a === "--version" || a === "-v") opts.version = true;
|
|
46
|
+
else if (a === "--force" || a === "-f") opts.force = true;
|
|
47
|
+
else if (a === "--cursor") opts.flavor = "cursor";
|
|
48
|
+
else if (a === "--claude") opts.flavor = "claude";
|
|
49
|
+
else if (a === "--dir") opts.target = argv[++i];
|
|
50
|
+
else if (a.startsWith("--dir=")) opts.target = a.slice("--dir=".length);
|
|
51
|
+
else {
|
|
52
|
+
console.error(c("yellow", `Unknown argument: ${a}`));
|
|
53
|
+
opts.help = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return opts;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function printHelp() {
|
|
60
|
+
console.log(`
|
|
61
|
+
${c("bold", "SHELEG Design")} ${c("dim", "v" + pkg.version)}
|
|
62
|
+
Install the SHELEG Design skill into your project.
|
|
63
|
+
|
|
64
|
+
${c("bold", "Usage")}
|
|
65
|
+
npx sheleg-design-skill [options]
|
|
66
|
+
|
|
67
|
+
${c("bold", "Options")}
|
|
68
|
+
--cursor Install to .cursor/skills/${SKILL_SLUG}/
|
|
69
|
+
--claude Install to .claude/skills/${SKILL_SLUG}/
|
|
70
|
+
--dir <path> Install to a custom directory
|
|
71
|
+
--force, -f Overwrite existing files
|
|
72
|
+
--help, -h Show this help
|
|
73
|
+
--version, -v Show version
|
|
74
|
+
|
|
75
|
+
${c("bold", "Default")}
|
|
76
|
+
Auto-detects: uses .cursor/ if present, else .claude/ if present,
|
|
77
|
+
otherwise creates .cursor/skills/${SKILL_SLUG}/.
|
|
78
|
+
|
|
79
|
+
${c("bold", "What it installs")}
|
|
80
|
+
SKILL.md the agent-facing skill (discovery + principles)
|
|
81
|
+
SHELEG_DESIGN.md the full reference (architecture, recipes, why it works)
|
|
82
|
+
`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveTargetDir(opts, cwd) {
|
|
86
|
+
if (opts.target) return path.resolve(cwd, opts.target);
|
|
87
|
+
if (opts.flavor === "cursor")
|
|
88
|
+
return path.join(cwd, ".cursor", "skills", SKILL_SLUG);
|
|
89
|
+
if (opts.flavor === "claude")
|
|
90
|
+
return path.join(cwd, ".claude", "skills", SKILL_SLUG);
|
|
91
|
+
|
|
92
|
+
const hasCursor = fs.existsSync(path.join(cwd, ".cursor"));
|
|
93
|
+
const hasClaude = fs.existsSync(path.join(cwd, ".claude"));
|
|
94
|
+
if (hasCursor) return path.join(cwd, ".cursor", "skills", SKILL_SLUG);
|
|
95
|
+
if (hasClaude) return path.join(cwd, ".claude", "skills", SKILL_SLUG);
|
|
96
|
+
return path.join(cwd, ".cursor", "skills", SKILL_SLUG);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function main() {
|
|
100
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
101
|
+
|
|
102
|
+
if (opts.version) {
|
|
103
|
+
console.log(pkg.version);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (opts.help) {
|
|
107
|
+
printHelp();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const cwd = process.cwd();
|
|
112
|
+
const targetDir = resolveTargetDir(opts, cwd);
|
|
113
|
+
|
|
114
|
+
// Verify the bundle is intact before touching the filesystem.
|
|
115
|
+
for (const f of FILES) {
|
|
116
|
+
if (!fs.existsSync(path.join(SKILL_DIR, f))) {
|
|
117
|
+
console.error(
|
|
118
|
+
c("yellow", `Bundle is missing ${f}. This is a packaging bug.`),
|
|
119
|
+
);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const existing = FILES.filter((f) =>
|
|
125
|
+
fs.existsSync(path.join(targetDir, f)),
|
|
126
|
+
);
|
|
127
|
+
if (existing.length && !opts.force) {
|
|
128
|
+
console.error(
|
|
129
|
+
`\n${c("yellow", "Refusing to overwrite existing files:")}\n` +
|
|
130
|
+
existing.map((f) => " " + path.join(targetDir, f)).join("\n") +
|
|
131
|
+
`\n\nRe-run with ${c("bold", "--force")} to overwrite.\n`,
|
|
132
|
+
);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
137
|
+
for (const f of FILES) {
|
|
138
|
+
fs.copyFileSync(path.join(SKILL_DIR, f), path.join(targetDir, f));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const rel = path.relative(cwd, targetDir) || ".";
|
|
142
|
+
console.log(
|
|
143
|
+
`\n${c("green", "✓")} ${c("bold", "SHELEG Design")} installed to ${c("blue", rel + "/")}\n` +
|
|
144
|
+
` ${c("dim", "SKILL.md")} the agent skill\n` +
|
|
145
|
+
` ${c("dim", "SHELEG_DESIGN.md")} the full reference\n\n` +
|
|
146
|
+
`Your Cursor / Claude agent can now discover the skill and build\n` +
|
|
147
|
+
`cinematic, scroll-driven, particle-backed pages on its principles.\n\n` +
|
|
148
|
+
`${c("dim", "Docs: " + pkg.homepage)}\n`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sheleg-design-skill",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SHELEG Design — an agent skill for building cinematic, scroll-driven, particle-backed landing pages. Installs a SKILL.md + reference doc into your project so Cursor/Claude agents can build sites with a single-clock motion system, a scene-formation particle engine, and degrade-to-calm fallbacks.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"sheleg-design-skill": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"skill/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"type": "commonjs",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=16"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node bin/cli.js --help"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"skill",
|
|
23
|
+
"cursor",
|
|
24
|
+
"claude",
|
|
25
|
+
"agent-skill",
|
|
26
|
+
"design-system",
|
|
27
|
+
"motion",
|
|
28
|
+
"animation",
|
|
29
|
+
"particles",
|
|
30
|
+
"webgl",
|
|
31
|
+
"scroll",
|
|
32
|
+
"gsap",
|
|
33
|
+
"three",
|
|
34
|
+
"framer-motion",
|
|
35
|
+
"landing-page",
|
|
36
|
+
"sheleg"
|
|
37
|
+
],
|
|
38
|
+
"author": "ssheleg",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/ssheleg/sheleg-design-skill.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/ssheleg/sheleg-design-skill#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/ssheleg/sheleg-design-skill/issues"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
# SHELEG Design
|
|
2
|
+
|
|
3
|
+
A motion-and-particle interface system, reverse-engineered from the Nicegram
|
|
4
|
+
Business OS landing page. This document captures *how* the page reaches its
|
|
5
|
+
level — the layout discipline, the scroll narrative, the WebGL particle field,
|
|
6
|
+
the DOM choreography, and the registration tricks that fuse them — so you can
|
|
7
|
+
build new sites on the same principles and understand *why* each piece works.
|
|
8
|
+
|
|
9
|
+
> SHELEG Design is the **motion + systems** layer. It assumes a **visual**
|
|
10
|
+
> system already exists (color, type, elevation, components — the reference
|
|
11
|
+
> implementation calls its visual layer "The Instrument Console": a near-black
|
|
12
|
+
> aerospace console with one electric-blue signal accent). Build the visual
|
|
13
|
+
> system first; layer SHELEG Design on top.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 0. The one-paragraph thesis
|
|
18
|
+
|
|
19
|
+
A landing page feels "alive" not because it has many animations, but because a
|
|
20
|
+
**single source of truth** (scroll position) drives **many cheap, layered
|
|
21
|
+
responses** that are individually quiet and collectively cinematic. SHELEG
|
|
22
|
+
Design centralizes scroll into one external store, then lets independent layers
|
|
23
|
+
(a WebGL particle field, a DOM spotlight, parallax figures, scrubbed
|
|
24
|
+
instruments, a progress rail) read that store **per frame** and react in their
|
|
25
|
+
own language. Every layer degrades to a calm static state. Nothing crossfades;
|
|
26
|
+
things *redeploy*. The result reads as one instrument responding to your hand,
|
|
27
|
+
not a pile of effects.
|
|
28
|
+
|
|
29
|
+
The five principles that make it work:
|
|
30
|
+
|
|
31
|
+
1. **One clock.** All motion derives from a single measured scroll state. Layers
|
|
32
|
+
never measure scroll independently, so they can never disagree.
|
|
33
|
+
2. **Read per frame, notify rarely.** Hot consumers (WebGL, canvas, rail) read
|
|
34
|
+
the store imperatively each frame and never trigger React renders. Only
|
|
35
|
+
coarse, human-visible changes (the current "act") notify React.
|
|
36
|
+
3. **Hold, then redeploy.** A formation is held steady for most of a section,
|
|
37
|
+
then morphs in a short, phase-staggered, arc-curved wave — points fly to new
|
|
38
|
+
posts. Crossfades are banned.
|
|
39
|
+
4. **Earned motion.** Scrubbed animation belongs to instruments that narrate
|
|
40
|
+
state over time. Hover/entrance motion is sub-500ms and never gates content.
|
|
41
|
+
5. **Degrade to calm.** `prefers-reduced-motion` / coarse pointer / no-WebGL all
|
|
42
|
+
collapse to a static, fully-legible page. The effect is an enhancement, never
|
|
43
|
+
a dependency.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 1. Architecture at a glance
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
┌─────────────────────────────┐
|
|
51
|
+
wheel / touch ─────▶ │ Lenis smooth scroll │ (SmoothScroll.tsx)
|
|
52
|
+
│ driven by the GSAP ticker │
|
|
53
|
+
└──────────────┬───────────────┘
|
|
54
|
+
│ one scroll position / frame
|
|
55
|
+
▼
|
|
56
|
+
┌─────────────────────────────┐
|
|
57
|
+
│ Scroll store (single clock) │ (scroll-progress.ts)
|
|
58
|
+
│ global · act · form · focusX │
|
|
59
|
+
│ velocity · finale │
|
|
60
|
+
└──────────────┬───────────────┘
|
|
61
|
+
per-frame reads (no React) │ coarse "act" change → React
|
|
62
|
+
┌───────────────┬───────────────┼───────────────┬──────────────┐
|
|
63
|
+
▼ ▼ ▼ ▼ ▼
|
|
64
|
+
SignalField SignalMesh FocalSpotlight ParallaxDrift ScrollRail
|
|
65
|
+
(WebGL field) (2D fallback) (dim off-band) (figure depth) (progress)
|
|
66
|
+
│
|
|
67
|
+
│ projects anchor points / reads chart progress
|
|
68
|
+
▼
|
|
69
|
+
field-sync bridge ──▶ ConstellationOverlay (SVG drawn over clusters)
|
|
70
|
+
◀── WhyNowChart scrub (particles assemble with the line)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Layers are **independent**. You can delete `ParallaxDrift` or the overlay and
|
|
74
|
+
everything else still works. That decoupling is the point: each layer is a small
|
|
75
|
+
file with one responsibility, reading the same clock.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 2. Layer 1 — The scroll store (the single clock)
|
|
80
|
+
|
|
81
|
+
**File:** `src/lib/motion/scroll-progress.ts`
|
|
82
|
+
|
|
83
|
+
This is the heart of the system. One `requestAnimationFrame` loop (scheduled on
|
|
84
|
+
the native `scroll` event) measures the page and writes a plain `state` object.
|
|
85
|
+
Everything else reads it.
|
|
86
|
+
|
|
87
|
+
### The dual-subscription pattern
|
|
88
|
+
|
|
89
|
+
The store exposes two read paths, which is what keeps it cheap:
|
|
90
|
+
|
|
91
|
+
- `getScrollState()` — returns the **live, mutable** object. Hot per-frame
|
|
92
|
+
consumers (WebGL `useFrame`, the canvas loop, the rail fill) read this and
|
|
93
|
+
never cause a React render.
|
|
94
|
+
- `subscribeAct()` + `useSyncExternalStore` — fires **only when the integer act
|
|
95
|
+
changes** (5 acts on the whole page). React components that show act-derived
|
|
96
|
+
UI (nav badge, rail markers) subscribe here, so they wake up a handful of
|
|
97
|
+
times per full scroll, not 60×/second.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// Per-frame, no React (rail fill, WebGL, canvas):
|
|
101
|
+
const s = getScrollState();
|
|
102
|
+
el.style.transform = `scaleY(${s.global})`;
|
|
103
|
+
|
|
104
|
+
// Coarse, React-driven (nav badge "02 / CONTROL"):
|
|
105
|
+
const act = useSyncExternalStore(subscribeAct, () => getScrollState().act, () => 0);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
> **Why it works:** the expensive consumers are exactly the ones that must run
|
|
109
|
+
> every frame anyway (rendering), so reading a mutable object is free. The cheap
|
|
110
|
+
> consumers (text labels) are the only ones in React's render path, and they
|
|
111
|
+
> change rarely. You get 60fps motion with almost no React churn.
|
|
112
|
+
|
|
113
|
+
### What the state carries
|
|
114
|
+
|
|
115
|
+
| Field | Range | Meaning | Who reads it |
|
|
116
|
+
|---|---|---|---|
|
|
117
|
+
| `global` | 0..1 | whole-page scroll progress | rail fill, camera dolly |
|
|
118
|
+
| `act` | 0..4 | current narrative act (integer) | nav badge, rail markers |
|
|
119
|
+
| `actProgress` | 0..1 | progress within the act | act-level copy |
|
|
120
|
+
| `field` | 0..4 | continuous act scalar | 2D mesh gain, climax |
|
|
121
|
+
| `scene` | 0..13 | current section's scene (integer) | scene lookup |
|
|
122
|
+
| `form` | 0..13 | **continuous scene scalar** | particle formation morph |
|
|
123
|
+
| `focusX` | -1..1 | camera pan target | WebGL camera, overlay |
|
|
124
|
+
| `velocity` | -1..1 | smoothed scroll speed | field breathing, rail |
|
|
125
|
+
| `finale` | 0..1 | post-waitlist epilogue progress | N charge → burst |
|
|
126
|
+
| `bias` | -0.6..1 | interactive nudge added to `form` | section gestures |
|
|
127
|
+
|
|
128
|
+
### The "hold then morph" measurement (the calm secret)
|
|
129
|
+
|
|
130
|
+
A naïve scroll-to-formation mapping changes the backdrop continuously, which
|
|
131
|
+
reads as nervous. The fix is a **hold window**. Within a scene's scroll span,
|
|
132
|
+
`form` stays pinned to the scene's integer for the first `HOLD` (82%), then
|
|
133
|
+
eases through the tail via smoothstep:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const sp = spanProgress(y, sceneFrom, sceneTo); // 0..1 within the scene
|
|
137
|
+
const HOLD = 0.82; // formation locked 82% of the span
|
|
138
|
+
const tail = clamp01((sp - HOLD) / (1 - HOLD)); // 0..1 only in the last 18%
|
|
139
|
+
const eased = tail * tail * (3 - 2 * tail); // smoothstep
|
|
140
|
+
state.form = scene + eased; // integer hold → eased morph at the seam
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Boundaries are measured when a section crosses **65% of the viewport** (`measure()`),
|
|
144
|
+
so a formation "opens" as its section becomes the reading focus. A
|
|
145
|
+
`ResizeObserver` on `document.body` re-measures when late layout (fonts, lazy
|
|
146
|
+
GSAP pin spacers) shifts the page — without it, every boundary below a pinned
|
|
147
|
+
section would be wrong.
|
|
148
|
+
|
|
149
|
+
### Velocity as a feeling, not a value
|
|
150
|
+
|
|
151
|
+
`setScrollVelocity()` is written from Lenis on every scroll event and normalized
|
|
152
|
+
to roughly -1..1. It **does not notify React** — it is read per frame by the
|
|
153
|
+
field (particles breathe faster while you scroll) and the rail (fill brightens
|
|
154
|
+
with input speed). This is the cheapest possible "the instrument is responding
|
|
155
|
+
to you" signal.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 3. Layer 2 — Smooth scroll (one position per frame)
|
|
160
|
+
|
|
161
|
+
**File:** `src/components/v2/motion/SmoothScroll.tsx`
|
|
162
|
+
|
|
163
|
+
Lenis provides inertial scrolling, but the important move is **driving Lenis
|
|
164
|
+
from the GSAP ticker** rather than its own rAF:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const lenis = new Lenis({ lerp: 0.09, wheelMultiplier: 1, touchMultiplier: 1.4 });
|
|
168
|
+
gsap.ticker.add((time) => lenis.raf(time * 1000));
|
|
169
|
+
gsap.ticker.lagSmoothing(0);
|
|
170
|
+
lenis.on("scroll", (e) => { setScrollVelocity(e.velocity); ScrollTrigger.update(); });
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
> **Why it works:** ScrollTrigger scrubs and the particle field both read scroll
|
|
174
|
+
> on the same tick, so a scrubbed chart and the background particles share one
|
|
175
|
+
> inertia — they move as a single instrument. A low `lerp` (0.09) means more
|
|
176
|
+
> glide between wheel notches, which is what lets formations morph as continuous
|
|
177
|
+
> motion instead of discrete jumps.
|
|
178
|
+
|
|
179
|
+
Reduced-motion / coarse-pointer clients skip Lenis entirely (`shouldReduceScenes()`)
|
|
180
|
+
and keep native scroll. The store still runs, so the rail and nav stay in sync.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 4. Layer 3 — The particle field (the scene-formation engine)
|
|
185
|
+
|
|
186
|
+
**File:** `src/components/v2/webgl/SignalField.tsx`
|
|
187
|
+
|
|
188
|
+
This is the showpiece: ~936 points (`24 × 13 × 3`) that narrate the page section
|
|
189
|
+
by section. It is a single `THREE.Points` cloud whose target positions change
|
|
190
|
+
per scene. All motion is CPU positional lerp — cheap, deterministic, tinted only
|
|
191
|
+
with the brand accent.
|
|
192
|
+
|
|
193
|
+
### 4.1 The scene registry
|
|
194
|
+
|
|
195
|
+
**File:** `src/lib/motion/scenes.ts`
|
|
196
|
+
|
|
197
|
+
Each section "owns" a scene; a scene is a `{ anchor, formation, focusX, energy }`
|
|
198
|
+
record. The registry is the single place that maps DOM sections to particle
|
|
199
|
+
behavior:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
export const SCENES = [
|
|
203
|
+
{ anchor: "top", formation: "frame", focusX: -0.3, energy: 0.5 },
|
|
204
|
+
{ anchor: "telemetry", formation: "curve", focusX: 0, energy: 0.45 },
|
|
205
|
+
{ anchor: "problem", formation: "turbulence", focusX: -0.25, energy: 0.55 },
|
|
206
|
+
// … one per section …
|
|
207
|
+
{ anchor: "why-now", formation: "gapCurve", focusX: 0, energy: 0.7 },
|
|
208
|
+
{ anchor: "waitlist", formation: "glyphN", focusX: 0, energy: 1 },
|
|
209
|
+
] as const;
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- `formation` — which shape the points fly into (frame, curve, lattice, triad,
|
|
213
|
+
orbit, constellation, ecoMap, gapCurve, glyphN, …).
|
|
214
|
+
- `focusX` — where the camera pans (opposite the content column, so the field
|
|
215
|
+
frames the text instead of fighting it).
|
|
216
|
+
- `energy` — the brightness budget for the scene (the **One Signal Rule**: the
|
|
217
|
+
glow never out-shouts an on-stage demo). `sceneEnergy(form)` interpolates it
|
|
218
|
+
continuously.
|
|
219
|
+
|
|
220
|
+
> **Why it works:** adding/reordering a scene is a one-line data edit. The field,
|
|
221
|
+
> the 2D fallback, and the overlay all resolve their behavior **from the registry
|
|
222
|
+
> by formation name** (`SCENES.findIndex(s => s.formation === "glyphN")`), so
|
|
223
|
+
> nothing hard-codes an index. The data is the choreography.
|
|
224
|
+
|
|
225
|
+
### 4.2 Formation builders (deterministic geometry)
|
|
226
|
+
|
|
227
|
+
`buildFormations()` produces each formation's target positions once, at mount.
|
|
228
|
+
Every builder runs its **own seeded RNG** (`mulberry32(seed)`), so:
|
|
229
|
+
|
|
230
|
+
- formations are identical across sessions and machines (no hydration drift),
|
|
231
|
+
- they are independent of declaration order,
|
|
232
|
+
- the same point index can be given a meaningful role in multiple formations
|
|
233
|
+
(e.g. the "tight N" variant reuses the N's per-point stroke assignment so the
|
|
234
|
+
charge densification reads as the letter pulling itself sharp).
|
|
235
|
+
|
|
236
|
+
Example — the "constellation" formation pulls a relaxed grid toward 8 hubs with
|
|
237
|
+
tight jitter so clusters read as deliberate nodes:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
const pull = 0.68; // strong pull → hubs read as clusters
|
|
241
|
+
out[ix] = gx + (hub[0] - gx) * pull + (rand() - 0.5) * 1.1; // small jitter
|
|
242
|
+
out[ix+1] = gy + (hub[1] - gy) * pull + (rand() - 0.5) * 1.1;
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### 4.3 The morph (hold → smoothstep stagger → arc)
|
|
246
|
+
|
|
247
|
+
Inside `useFrame`, the render-side scalar `s.form` chases the store's `form` with
|
|
248
|
+
**clamped critically-damped smoothing**, so flick-scrolls and anchor jumps sweep
|
|
249
|
+
through intermediate formations cinematically instead of teleporting:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
const formDelta = (scroll.form + scroll.bias - s.form) * 0.028;
|
|
253
|
+
s.form += Math.max(-0.04, Math.min(0.04, formDelta)); // per-frame delta cap
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Each point then blends between the two active formations with a **per-point
|
|
257
|
+
phase-staggered, smoothstepped** progress — points peel off in a wave and ease
|
|
258
|
+
in and out of their posts:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
const m = clamp01((mix - phaseNorm * spread) / (1 - spread)); // spread = 0.5
|
|
262
|
+
const eased = m * m * (3 - 2 * m); // smoothstep
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
And mid-flight, points swing **perpendicular to their travel line** — half
|
|
266
|
+
clockwise, half counter — so the swarm curls between formations instead of
|
|
267
|
+
sliding in straight lines:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
if (m > 0.001 && m < 0.999) {
|
|
271
|
+
const arc = Math.sin(m * Math.PI) * arcAmp * (phase > Math.PI ? 1 : -1);
|
|
272
|
+
px += (-dy / dist) * arc; // perpendicular offset, peaks mid-transition
|
|
273
|
+
py += ( dx / dist) * arc;
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
> **Why it works:** crossfades look like one image fading into another (flat,
|
|
278
|
+
> digital). A phase-staggered arc-curved redeploy looks like a *swarm of agents*
|
|
279
|
+
> flying to new posts (alive, physical). It is the single biggest reason the
|
|
280
|
+
> field reads as premium rather than as a screensaver.
|
|
281
|
+
|
|
282
|
+
### 4.4 Formation profiles (the glow programs)
|
|
283
|
+
|
|
284
|
+
A `PROFILES` table gives each formation a render character — how lattice-like it
|
|
285
|
+
is (drives wire opacity), which glow program lights it, and how much ambient
|
|
286
|
+
drift it keeps:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
const PROFILES = {
|
|
290
|
+
lattice: { lattice: 1, glow: "wave", drift: 0.12 }, // engineered grid + wave pulse
|
|
291
|
+
constellation: { lattice: 0, glow: "hubs", drift: 0.25 }, // hub stars twinkle, no wires
|
|
292
|
+
beam: { lattice: 0, glow: "beam", drift: 0.2 }, // focused vertical signal
|
|
293
|
+
glyphN: { lattice: 0, glow: "glyph", drift: 0.1 }, // the finale "N"
|
|
294
|
+
// …
|
|
295
|
+
};
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Glow programs are blended between the two active formations, so transitions
|
|
299
|
+
carry their lighting with them. Profiles are interpolated, never switched hard.
|
|
300
|
+
|
|
301
|
+
### 4.5 The camera
|
|
302
|
+
|
|
303
|
+
The camera does three quiet things, all smoothed:
|
|
304
|
+
|
|
305
|
+
- **Dolly** back as `global` increases (the page pulls away over its length).
|
|
306
|
+
- **Pan** toward the active scene's `focusX` (attention follows the content).
|
|
307
|
+
- **Tilt** a fraction toward the focus side — applied *after* `lookAt` (which
|
|
308
|
+
resets rotation), from its own smoothed value. This faint instrument tilt is
|
|
309
|
+
what gives the field a sense of being observed through a lens.
|
|
310
|
+
|
|
311
|
+
### 4.6 The finale (charge → burst), scrub-driven
|
|
312
|
+
|
|
313
|
+
Past the waitlist, a dedicated `finale` scalar (0..1) drives an epilogue that is
|
|
314
|
+
**reversible** because it is pure scrub:
|
|
315
|
+
|
|
316
|
+
- **Charge** (`finale 0 → 0.55`): the N densifies toward a tight core, scales up,
|
|
317
|
+
and trembles with rising amplitude; brightness and point size climb.
|
|
318
|
+
- **Burst** (`finale 0.55 → 0.85`): every point flies out along a precomputed
|
|
319
|
+
radial debris vector with a white-hot flash, then dims into an ember field.
|
|
320
|
+
- The DOM **closing line** ("Join Nicegram") fades up from the afterglow.
|
|
321
|
+
|
|
322
|
+
Because it is gated on the glyph actually holding (`finaleness`) and reads a
|
|
323
|
+
scrubbed scalar, scrolling back up rewinds the whole explosion frame-for-frame.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## 5. Layer 4 — The 2D fallback (SignalMesh)
|
|
328
|
+
|
|
329
|
+
**File:** `src/components/v2/atmosphere/SignalMesh.tsx`
|
|
330
|
+
|
|
331
|
+
Touch and no-WebGL clients get a canvas mesh: two depth layers of grid nodes
|
|
332
|
+
with hairline links and traveling pulses, plus **stroke overlays** that fade in
|
|
333
|
+
the curve / orbit / N around their scene windows. It reads the **same store**
|
|
334
|
+
(`getScrollState().form`, `sceneEnergy`, `finale`), so the escalation and the
|
|
335
|
+
finale charge/burst are mirrored — the page tells the same story at lower
|
|
336
|
+
fidelity. Only `prefers-reduced-motion` gets a single static frame.
|
|
337
|
+
|
|
338
|
+
> **Principle:** the fallback is not a stripped logo — it reads the same clock
|
|
339
|
+
> and tells the same narrative. That is why switching modes never feels like a
|
|
340
|
+
> different website.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## 6. Layer 5 — The projection bridge (fusing DOM and WebGL)
|
|
345
|
+
|
|
346
|
+
**File:** `src/lib/motion/field-sync.ts`
|
|
347
|
+
|
|
348
|
+
The hardest problem in mixing WebGL particles with DOM labels: the camera moves,
|
|
349
|
+
so you cannot hard-code where a cluster appears on screen. The bridge solves it
|
|
350
|
+
with a tiny zero-allocation, no-React module that the field writes and the DOM
|
|
351
|
+
reads — the same contract as `setScrollVelocity`.
|
|
352
|
+
|
|
353
|
+
### 6.1 Constellation registration (ecosystem section)
|
|
354
|
+
|
|
355
|
+
Every frame, `SignalField` projects the constellation's world-space anchors
|
|
356
|
+
through its **own camera** and publishes viewport-pixel coordinates + a
|
|
357
|
+
visibility alpha:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
// In SignalField useFrame, after all camera moves:
|
|
361
|
+
PROJ_SCRATCH.set(anchor.x, anchor.y, -2).project(camera);
|
|
362
|
+
setEcoProjection(node,
|
|
363
|
+
(PROJ_SCRATCH.x * 0.5 + 0.5) * size.width,
|
|
364
|
+
(0.5 - PROJ_SCRATCH.y * 0.5) * size.height);
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`ConstellationOverlay.tsx` (a fixed SVG) reads those points in its own rAF and
|
|
368
|
+
positions the constellation lines, the astronomy-chart labels (with leader
|
|
369
|
+
ticks), and the rotating "you are here" reticle — perfectly registered over the
|
|
370
|
+
particle clusters at any scroll position. CSS `is-live` (toggled at `alpha > 0.5`)
|
|
371
|
+
sequences the draw-in: lines stroke themselves, then labels stagger, then the
|
|
372
|
+
reticle scales in and spins.
|
|
373
|
+
|
|
374
|
+
> **Why it works:** the SVG never guesses. It uses the exact projection the GPU
|
|
375
|
+
> used that frame, so the "interface drawing the constellation" illusion holds
|
|
376
|
+
> through dolly, pan, and tilt. Cost: 4 `Vector3.project` calls per frame ≈ zero.
|
|
377
|
+
|
|
378
|
+
### 6.2 Chart participation (why-now section)
|
|
379
|
+
|
|
380
|
+
The reverse direction: the chart's GSAP scrub publishes its draw progress, and
|
|
381
|
+
the field assembles its particle copy of the curve left-to-right in lockstep:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
// WhyNowChart timeline:
|
|
385
|
+
onUpdate: () => setChartProgress(tl.progress())
|
|
386
|
+
|
|
387
|
+
// SignalField, per point of the gapCurve formation:
|
|
388
|
+
const u = (px + HALF) / (HALF * 2); // 0..1 along the curve
|
|
389
|
+
if (u > chartProg + 0.04) { py -= drop; lum *= 0.5; } // not yet assembled
|
|
390
|
+
else lum += max(0, 1 - |u - chartProg| * 16) * 0.8; // bright head at the draw front
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
The SVG line and the particles share one scrub and one inertia, so the bright
|
|
394
|
+
particle "head" runs exactly where the line tip is being drawn.
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## 7. Layer 6 — DOM choreography
|
|
399
|
+
|
|
400
|
+
Three small components, one mechanism each, all reading the same clock.
|
|
401
|
+
|
|
402
|
+
### FocalSpotlight — `src/components/v2/motion/FocalSpotlight.tsx`
|
|
403
|
+
An `IntersectionObserver` with a generous center band (`rootMargin: -22% 0 -22% 0`)
|
|
404
|
+
toggles a `data-dim` attribute on sections outside the band. CSS dims them to
|
|
405
|
+
62% opacity. The reader's eye is always pulled to the active section. The finale
|
|
406
|
+
section is excluded (`:not(#finale)`) because it runs its own fade.
|
|
407
|
+
|
|
408
|
+
### ParallaxDrift — `src/components/v2/motion/ParallaxDrift.tsx`
|
|
409
|
+
Wraps **at most one figure per viewport** in a GSAP `yPercent` scrub for a few
|
|
410
|
+
percent of depth drift. Restraint is the rule: parallax on everything is nausea;
|
|
411
|
+
parallax on the one hero figure is depth.
|
|
412
|
+
|
|
413
|
+
### ScrollRail — `src/components/v2/atmosphere/ScrollRail.tsx`
|
|
414
|
+
A right-edge hairline whose fill is scaled imperatively (`scaleY(global)`) and
|
|
415
|
+
brightened by `velocity` — a live mission timeline, updated per frame with zero
|
|
416
|
+
React renders. Act markers deep-link to their anchor sections and use the coarse
|
|
417
|
+
`subscribeAct` path for their active state.
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
## 8. Layer 7 — Reveal primitives (act-themed entrances)
|
|
422
|
+
|
|
423
|
+
**File:** `src/components/v2/design/Reveal.tsx`
|
|
424
|
+
|
|
425
|
+
Entrances are not generic. Each narrative act has a reveal whose *physics* match
|
|
426
|
+
its meaning — this is Disney's "staging" applied to a scroll page:
|
|
427
|
+
|
|
428
|
+
| Reveal | Physics | Act it serves |
|
|
429
|
+
|---|---|---|
|
|
430
|
+
| `ScatterReveal` | drifts in off-axis with a blur that resolves | **Problem** — unmanaged things settling into frame |
|
|
431
|
+
| `LockReveal` | scales down a hair and snaps into place | **Control** — a part machined into a slot |
|
|
432
|
+
| `ClipReveal` | mechanical left-to-right clip wipe | headlines, panels |
|
|
433
|
+
| `PulseReveal` | a single soft pulse as it "acquires lock" | **Signal** — the waitlist climax |
|
|
434
|
+
| `Stagger` / `StaggerItem` | children cascade with a 0.06s stagger | lists, readouts |
|
|
435
|
+
|
|
436
|
+
**Every reveal renders its final state plainly under `prefers-reduced-motion`** —
|
|
437
|
+
the `useReducedMotion()` branch returns the plain tag. Entrances enhance; they
|
|
438
|
+
never gate whether content is visible.
|
|
439
|
+
|
|
440
|
+
> **Why it works:** when the *kind* of entrance matches the *meaning* of the
|
|
441
|
+
> section, motion becomes narration. The page isn't decorated; it's told.
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
## 9. Layer 8 — Scrubbed instruments (GSAP recipe)
|
|
446
|
+
|
|
447
|
+
Diagrams that narrate state over time (growth chart, comparison table, the
|
|
448
|
+
pinned three-step flow, the why-now chart) use one repeatable GSAP recipe.
|
|
449
|
+
|
|
450
|
+
**Files:** `WhyNowChart.tsx`, `EcosystemDiagram.tsx`, `PinnedSteps.tsx`, `gsap-client.ts`
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
useLayoutEffect(() => {
|
|
454
|
+
if (shouldReduceScenes()) return; // static, fully-drawn fallback
|
|
455
|
+
let teardown;
|
|
456
|
+
loadGsap().then(({ gsap, ScrollTrigger }) => { // lazy: GSAP never in initial bundle
|
|
457
|
+
const tl = gsap.timeline({
|
|
458
|
+
defaults: { ease: "none" }, // ease: 'none' is mandatory with scrub
|
|
459
|
+
scrollTrigger: { trigger: svg, start: "top 85%", end: "top 25%", scrub: 0.8 },
|
|
460
|
+
});
|
|
461
|
+
tl.fromTo(lines,
|
|
462
|
+
{ strokeDasharray: 1, strokeDashoffset: 1 }, // pathLength={1} normalizes every path
|
|
463
|
+
{ strokeDashoffset: 0, stagger: 0.08 }); // → one variable draws them all
|
|
464
|
+
teardown = () => { tl.scrollTrigger?.kill(); tl.kill(); }; // ALWAYS kill on cleanup
|
|
465
|
+
ScrollTrigger.refresh();
|
|
466
|
+
});
|
|
467
|
+
return () => teardown?.();
|
|
468
|
+
}, []);
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
The non-negotiables (each learned from a real bug here):
|
|
472
|
+
|
|
473
|
+
- **Lazy-load GSAP** (`loadGsap()`), register the plugin once, keep it out of the
|
|
474
|
+
initial bundle.
|
|
475
|
+
- **`ease: 'none'`** on scrubbed tweens — easing fights the scrub.
|
|
476
|
+
- **`pathLength={1}`** on SVG paths so a single 0..1 variable can draw any path,
|
|
477
|
+
regardless of its real length.
|
|
478
|
+
- **Always `tl.kill()` + `scrollTrigger.kill()`** in cleanup — un-killed
|
|
479
|
+
timelines leak and double up on fast-refresh / route changes.
|
|
480
|
+
- **Reduced-motion renders the final drawn state** with no trigger attached.
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
484
|
+
## 10. Cross-cutting rules
|
|
485
|
+
|
|
486
|
+
### Motion tokens — `src/lib/motion/tokens.ts`
|
|
487
|
+
No component invents its own curve. Everything uses:
|
|
488
|
+
|
|
489
|
+
- **`EASE = cubic-bezier(0.16, 1, 0.3, 1)`** (an easeOutExpo-like signature),
|
|
490
|
+
mirrored in CSS as `--v2-ease`.
|
|
491
|
+
- **`DUR`** — `fast 0.18` / `base 0.32` / `slow 0.55` / `epic 0.8` seconds.
|
|
492
|
+
- **`STAGGER = 0.07`** — the standard interval between sibling reveals.
|
|
493
|
+
|
|
494
|
+
One ease + a tiny duration set is what makes twelve independent animations feel
|
|
495
|
+
like one designed system rather than twelve developers' defaults.
|
|
496
|
+
|
|
497
|
+
### The fallback policy (single source: `shouldReduceScenes()`)
|
|
498
|
+
`gsap-client.ts` centralizes the decision: `prefers-reduced-motion` **or**
|
|
499
|
+
`pointer: coarse` ⇒ skip motion-heavy scenes. Consequences everywhere:
|
|
500
|
+
|
|
501
|
+
- WebGL field → 2D `SignalMesh`.
|
|
502
|
+
- Lenis smooth scroll → native scroll.
|
|
503
|
+
- Pinned scrub scenes → static fully-drawn diagrams.
|
|
504
|
+
- Reveals → final state, instant.
|
|
505
|
+
- Constellation overlay → not mounted; section shows the boxed diagram.
|
|
506
|
+
|
|
507
|
+
### Performance budget
|
|
508
|
+
- WebGL is **always** `dynamic(() => import(...), { ssr: false })` — never in the
|
|
509
|
+
first bundle, mounted one frame after hydration paints.
|
|
510
|
+
- Hot loops mutate typed arrays / plain objects and **never call `setState`**.
|
|
511
|
+
- Animate only `transform` and `opacity` in DOM/CSS; avoid `width`/`height`/
|
|
512
|
+
`box-shadow` transitions.
|
|
513
|
+
- Pinned scroll is budgeted at ≈30% of total page height.
|
|
514
|
+
- Particle count (936) and DPR cap (`[1, 1.75]`) keep the field on a
|
|
515
|
+
`low-power` GL context.
|
|
516
|
+
|
|
517
|
+
### Accessibility parity
|
|
518
|
+
Tabs follow WAI-ARIA roving-tabindex (`use-tabs.ts`); the mobile nav is a
|
|
519
|
+
focus-trapped `role="dialog"`; every canvas/field is `aria-hidden` with a
|
|
520
|
+
text-equivalent in the DOM (e.g. the ecosystem stage keeps the diagram's
|
|
521
|
+
`aria-label` as `sr-only`). Motion is never the only carrier of meaning.
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
## 11. Recipe — build a new SHELEG site from scratch
|
|
526
|
+
|
|
527
|
+
A pragmatic order that front-loads the parts everything else depends on.
|
|
528
|
+
|
|
529
|
+
1. **Lay the visual system first.** Implement `DESIGN.md`: near-black canvas,
|
|
530
|
+
one signal accent, hairline structure, mono telemetry labels, the panel /
|
|
531
|
+
button / status-pill components. Motion on top of a weak visual system just
|
|
532
|
+
amplifies the weakness.
|
|
533
|
+
|
|
534
|
+
2. **Stand up the clock.** Port `scroll-progress.ts`: the `state` object, the
|
|
535
|
+
dual subscriptions (`getScrollState` for per-frame, `subscribeAct` for
|
|
536
|
+
React), the rAF `update()`, the 65%-viewport `measure()`, and the
|
|
537
|
+
`ResizeObserver` re-measure. Verify with a temporary readout of `global` /
|
|
538
|
+
`act` / `form`.
|
|
539
|
+
|
|
540
|
+
3. **Add smooth scroll.** `SmoothScroll.tsx` with Lenis on the GSAP ticker,
|
|
541
|
+
feeding `setScrollVelocity`. Gate it behind `shouldReduceScenes()`.
|
|
542
|
+
|
|
543
|
+
4. **Define scenes.** Write your `scenes.ts` registry — one `{ anchor,
|
|
544
|
+
formation, focusX, energy }` per section. Give each section an `id` matching
|
|
545
|
+
its anchor. This is your storyboard; iterate on it in data before touching
|
|
546
|
+
rendering.
|
|
547
|
+
|
|
548
|
+
5. **Build the field.** `SignalField.tsx`: a seeded point cloud, one builder per
|
|
549
|
+
formation, the `PROFILES` table, and the `useFrame` morph (clamped smoothing
|
|
550
|
+
+ per-point smoothstep stagger + arcs). Get *one* formation looking right,
|
|
551
|
+
then add the rest as data.
|
|
552
|
+
|
|
553
|
+
6. **Mirror it in 2D.** `SignalMesh.tsx` reading the same store, so touch / no-
|
|
554
|
+
WebGL tells the same story. Wire the `AtmosphereField` capability probe.
|
|
555
|
+
|
|
556
|
+
7. **Layer the DOM choreography.** `FocalSpotlight`, `ParallaxDrift` (one figure
|
|
557
|
+
per viewport), `ScrollRail`. Each is tiny and independent.
|
|
558
|
+
|
|
559
|
+
8. **Theme the entrances.** Build the `Reveal` family; assign a reveal physics to
|
|
560
|
+
each act's meaning. Always branch reduced-motion to plain.
|
|
561
|
+
|
|
562
|
+
9. **Add scrubbed instruments** with the Section 9 GSAP recipe for any diagram
|
|
563
|
+
that narrates state over time.
|
|
564
|
+
|
|
565
|
+
10. **Fuse DOM ↔ WebGL where it earns it.** Only when you want labels exactly on
|
|
566
|
+
clusters or particles synced to a chart: add the `field-sync` bridge, project
|
|
567
|
+
anchors in `useFrame`, read them in a fixed SVG overlay.
|
|
568
|
+
|
|
569
|
+
11. **Pay the fallback + a11y tax as you go**, not at the end. Every layer ships
|
|
570
|
+
with its reduced-motion branch in the same commit.
|
|
571
|
+
|
|
572
|
+
12. **Verify like the runbook:** `tsc --noEmit`, `eslint`, `build` clean;
|
|
573
|
+
screenshot each scene mid-hold and mid-morph; emulate reduced-motion; check a
|
|
574
|
+
~390px viewport. Then deploy.
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
## 12. Why it works (the deeper principles)
|
|
579
|
+
|
|
580
|
+
- **A single clock removes disagreement.** Most "janky" multi-effect pages have
|
|
581
|
+
each effect measuring scroll on its own schedule; they drift apart by a frame
|
|
582
|
+
and the eye reads chaos. One measured state, read by all, makes layers
|
|
583
|
+
*phase-locked* — they look intentional because they literally share a clock.
|
|
584
|
+
|
|
585
|
+
- **Cheap-but-many beats expensive-but-few.** 936 points doing simple positional
|
|
586
|
+
lerps, plus five DOM layers reading a mutable object, costs less than one
|
|
587
|
+
heavy library effect — and reads richer, because richness comes from *layers
|
|
588
|
+
agreeing*, not from any single layer's complexity.
|
|
589
|
+
|
|
590
|
+
- **Redeploy, don't crossfade.** Human attention tracks *moving objects*. A
|
|
591
|
+
phase-staggered, arc-curved migration of points gives the eye things to
|
|
592
|
+
follow; a crossfade gives it nothing and reads as flat compositing.
|
|
593
|
+
|
|
594
|
+
- **Hold creates legibility; morph creates delight.** Long holds let each
|
|
595
|
+
formation be *understood* (it's the why-now curve, it's the constellation);
|
|
596
|
+
the short morph at the seam is the reward. Constant motion would sacrifice
|
|
597
|
+
both — nothing is understood and nothing is special.
|
|
598
|
+
|
|
599
|
+
- **Earned motion respects the reader.** Scrub is reserved for instruments that
|
|
600
|
+
genuinely encode time/state. Decorative scrub (parallax everything, scrub the
|
|
601
|
+
hero) is the tell of a template; restraint is the tell of a system.
|
|
602
|
+
|
|
603
|
+
- **Degrade-to-calm is a feature, not a chore.** Because every layer has a
|
|
604
|
+
static state, the page is *correct* on a low-power phone or for a motion-
|
|
605
|
+
sensitive user, and the rich version is a pure bonus. The fallback isn't a
|
|
606
|
+
worse site; it's the same site, quieter.
|
|
607
|
+
|
|
608
|
+
- **Data-driven choreography scales.** The whole narrative lives in one `SCENES`
|
|
609
|
+
array and a `PROFILES` table. Re-storyboarding the page is editing data, not
|
|
610
|
+
rewriting render loops. That is why this system can keep growing (ecosystem
|
|
611
|
+
constellation, chart particles, the N finale) without collapsing.
|
|
612
|
+
|
|
613
|
+
---
|
|
614
|
+
|
|
615
|
+
## 13. File map (where each idea lives)
|
|
616
|
+
|
|
617
|
+
| Concern | File |
|
|
618
|
+
|---|---|
|
|
619
|
+
| The clock / scroll state | `src/lib/motion/scroll-progress.ts` |
|
|
620
|
+
| Scene storyboard | `src/lib/motion/scenes.ts` |
|
|
621
|
+
| Motion tokens (ease/dur/stagger) | `src/lib/motion/tokens.ts` |
|
|
622
|
+
| Lazy GSAP + reduced-motion gate | `src/lib/motion/gsap-client.ts` |
|
|
623
|
+
| DOM↔WebGL bridge | `src/lib/motion/field-sync.ts` |
|
|
624
|
+
| Interactive field gestures | `src/lib/motion/use-section-field.ts` |
|
|
625
|
+
| WebGL particle field | `src/components/v2/webgl/SignalField.tsx` |
|
|
626
|
+
| 2D fallback field | `src/components/v2/atmosphere/SignalMesh.tsx` |
|
|
627
|
+
| Field/fallback mode switch | `src/components/v2/atmosphere/AtmosphereField.tsx` |
|
|
628
|
+
| Constellation SVG overlay | `src/components/v2/atmosphere/ConstellationOverlay.tsx` |
|
|
629
|
+
| Progress rail | `src/components/v2/atmosphere/ScrollRail.tsx` |
|
|
630
|
+
| Smooth scroll | `src/components/v2/motion/SmoothScroll.tsx` |
|
|
631
|
+
| Attention dimming | `src/components/v2/motion/FocalSpotlight.tsx` |
|
|
632
|
+
| Figure parallax | `src/components/v2/motion/ParallaxDrift.tsx` |
|
|
633
|
+
| Reveal primitives | `src/components/v2/design/Reveal.tsx` |
|
|
634
|
+
| Epilogue runway | `src/components/v2/sections/FinaleSectionV2.tsx` |
|
|
635
|
+
| Scrubbed instruments (examples) | `WhyNowChart.tsx`, `EcosystemDiagram.tsx`, `PinnedSteps.tsx` |
|
|
636
|
+
| CSS tokens + all `v2-*` styles | `src/app/v2.css` |
|
|
637
|
+
|
|
638
|
+
---
|
|
639
|
+
|
|
640
|
+
*SHELEG Design is the motion + systems half of this site's identity; pair it
|
|
641
|
+
with `DESIGN.md` for the visual half. The north star for both: it should feel
|
|
642
|
+
like a precision instrument responding to your hand — authority through
|
|
643
|
+
accuracy and restraint, not spectacle.*
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sheleg-design
|
|
3
|
+
description: Use when building or upgrading a cinematic, scroll-driven landing page, marketing site, or hero experience — especially one with a particle/WebGL background, scroll-linked animation, parallax, pinned/scrubbed sections, or formation-changing motion. SHELEG Design is a motion + systems methodology: one scroll "clock" drives many cheap, layered, independently-degradable responses (a particle field, a 2D fallback, attention dimming, parallax, scrubbed instruments, a progress rail) so the page reads as a single precision instrument. Read this before designing the motion architecture of such a site; it pairs with a visual system, not replaces one.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# SHELEG Design
|
|
7
|
+
|
|
8
|
+
A methodology for landing pages that feel *alive* without feeling busy. Full
|
|
9
|
+
reference (architecture, code-level mechanics, build-from-scratch recipe, file
|
|
10
|
+
map, and the deeper "why") lives in [`SHELEG_DESIGN.md`](./SHELEG_DESIGN.md) next
|
|
11
|
+
to this file — read it before implementing.
|
|
12
|
+
|
|
13
|
+
## The thesis
|
|
14
|
+
|
|
15
|
+
A page feels cinematic not from many animations, but from a **single source of
|
|
16
|
+
truth** (scroll position) driving **many cheap, layered responses** that are
|
|
17
|
+
individually quiet and collectively rich. Centralize scroll into one external
|
|
18
|
+
store; let independent layers read it per frame and react in their own language.
|
|
19
|
+
Nothing crossfades — things *redeploy*. Every layer degrades to a calm static
|
|
20
|
+
state.
|
|
21
|
+
|
|
22
|
+
## The five principles (apply in order)
|
|
23
|
+
|
|
24
|
+
1. **One clock.** All motion derives from one measured scroll state. Layers never
|
|
25
|
+
measure scroll independently, so they can never drift out of phase.
|
|
26
|
+
2. **Read per frame, notify rarely.** Hot consumers (WebGL/canvas/rail) read the
|
|
27
|
+
store imperatively each frame and cause zero React renders. Only coarse,
|
|
28
|
+
human-visible changes (the current "act"/section) notify the framework.
|
|
29
|
+
3. **Hold, then redeploy.** Hold a formation steady for ~80% of a section, then
|
|
30
|
+
morph in a short, phase-staggered, arc-curved wave. Ban crossfades.
|
|
31
|
+
4. **Earned motion.** Scrub belongs to instruments that narrate state over time
|
|
32
|
+
(charts, step flows). Hover/entrance motion stays sub-500ms and never gates
|
|
33
|
+
content visibility.
|
|
34
|
+
5. **Degrade to calm.** `prefers-reduced-motion` / coarse pointer / no-WebGL all
|
|
35
|
+
collapse to a static, fully-legible page. The effect is a bonus, never a
|
|
36
|
+
dependency.
|
|
37
|
+
|
|
38
|
+
## How to use this skill
|
|
39
|
+
|
|
40
|
+
When asked to build or upgrade such a site:
|
|
41
|
+
|
|
42
|
+
1. **Lay the visual system first** (color, type, spacing, components). Motion on
|
|
43
|
+
top of a weak visual system amplifies the weakness. SHELEG Design is the
|
|
44
|
+
motion layer — it assumes a visual foundation exists.
|
|
45
|
+
2. **Build bottom-up following the layer order** in `SHELEG_DESIGN.md` §11:
|
|
46
|
+
the scroll clock → smooth scroll → particle field → 2D fallback → DOM
|
|
47
|
+
choreography → reveal primitives → scrubbed instruments → (optional) DOM↔WebGL
|
|
48
|
+
bridge. Each layer is a small, single-responsibility file reading the one clock.
|
|
49
|
+
3. **Storyboard in data.** Express the narrative as a `SCENES` registry (one
|
|
50
|
+
`{ anchor, formation, focusX, energy }` per section). Iterate on the data
|
|
51
|
+
before touching render loops.
|
|
52
|
+
4. **Pay the fallback + a11y tax in the same commit** as each layer, never at the
|
|
53
|
+
end. Every animated component ships its reduced-motion branch immediately.
|
|
54
|
+
5. **Verify** with typecheck/lint/build, screenshots of each scene mid-hold and
|
|
55
|
+
mid-morph, a reduced-motion pass, and a narrow-viewport pass.
|
|
56
|
+
|
|
57
|
+
## Non-negotiables (each prevents a real failure mode)
|
|
58
|
+
|
|
59
|
+
- Centralize scroll in ONE store with two read paths: a live getter for
|
|
60
|
+
per-frame readers and a coarse subscription for framework-rendered UI.
|
|
61
|
+
- Hold-then-morph (long hold, short smoothstepped tail) — constant morphing
|
|
62
|
+
reads as nervous; this is the single biggest "calm" lever.
|
|
63
|
+
- Redeploy with a per-point phase-staggered, perpendicular-arc migration — this
|
|
64
|
+
is what makes a particle field read as premium rather than a screensaver.
|
|
65
|
+
- Drive smooth scroll (e.g. Lenis) from the animation library's ticker so
|
|
66
|
+
scrubbed instruments and the particle field share one inertia.
|
|
67
|
+
- Lazy-load heavy libs (GSAP/WebGL) out of the initial bundle; WebGL mounts one
|
|
68
|
+
frame after hydration paints.
|
|
69
|
+
- One ease + a tiny duration/stagger token set for the whole site; no component
|
|
70
|
+
invents its own curve.
|
|
71
|
+
- For scrubbed SVG: `ease: 'none'`, `pathLength={1}` to normalize paths, and
|
|
72
|
+
always kill timelines + triggers on cleanup.
|
|
73
|
+
- Animate only `transform` and `opacity`; reserve scrub for genuine instruments.
|
|
74
|
+
|
|
75
|
+
## When NOT to use it
|
|
76
|
+
|
|
77
|
+
Skip for static content sites, docs, dashboards, or anything where motion is not
|
|
78
|
+
a goal. Do not bolt the particle field onto a page whose visual system or copy
|
|
79
|
+
isn't finished — fix those first.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
Read [`SHELEG_DESIGN.md`](./SHELEG_DESIGN.md) for the full architecture, code
|
|
84
|
+
mechanics, the exact morph math, the DOM↔WebGL projection bridge, the
|
|
85
|
+
build-from-scratch recipe, and the file map.
|