@struq/skill 0.0.0-stage → 0.2.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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/bin/struq.mjs +132 -0
  4. package/package.json +41 -4
  5. package/skills/struq/SKILL.md +237 -0
  6. package/skills/struq/cinematic/README.md +409 -0
  7. package/skills/struq/cinematic/checklists/01-preproduction.md +15 -0
  8. package/skills/struq/cinematic/checklists/02-implementation.md +17 -0
  9. package/skills/struq/cinematic/checklists/03-polish.md +17 -0
  10. package/skills/struq/cinematic/examples/code/nextjs/README.md +13 -0
  11. package/skills/struq/cinematic/examples/code/nextjs/app/cinematic-experience.tsx +13 -0
  12. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +42 -0
  13. package/skills/struq/cinematic/examples/code/nextjs/app/page.tsx +6 -0
  14. package/skills/struq/cinematic/examples/code/shared/CameraRig.tsx +65 -0
  15. package/skills/struq/cinematic/examples/code/shared/ExperienceCanvas.tsx +20 -0
  16. package/skills/struq/cinematic/examples/code/shared/ExperienceDirector.tsx +57 -0
  17. package/skills/struq/cinematic/examples/code/shared/ExperienceOverlay.tsx +37 -0
  18. package/skills/struq/cinematic/examples/code/shared/ExperiencePage.tsx +52 -0
  19. package/skills/struq/cinematic/examples/code/shared/GsapFrameLoop.tsx +32 -0
  20. package/skills/struq/cinematic/examples/code/shared/InteriorRoot.tsx +30 -0
  21. package/skills/struq/cinematic/examples/code/shared/README.md +20 -0
  22. package/skills/struq/cinematic/examples/code/shared/SubjectRig.tsx +58 -0
  23. package/skills/struq/cinematic/examples/code/shared/build-experience-timeline.ts +237 -0
  24. package/skills/struq/cinematic/examples/code/shared/direction.ts +52 -0
  25. package/skills/struq/cinematic/examples/code/shared/gsap-client.ts +32 -0
  26. package/skills/struq/cinematic/examples/code/shared/math.ts +12 -0
  27. package/skills/struq/cinematic/examples/code/shared/registry.ts +58 -0
  28. package/skills/struq/cinematic/examples/code/shared/runtime.ts +52 -0
  29. package/skills/struq/cinematic/examples/code/shared/styles.css +127 -0
  30. package/skills/struq/cinematic/examples/code/shared/useExperienceInput.ts +42 -0
  31. package/skills/struq/cinematic/examples/code/vite/README.md +14 -0
  32. package/skills/struq/cinematic/examples/code/vite/src/App.tsx +11 -0
  33. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +37 -0
  34. package/skills/struq/cinematic/examples/code/vite/src/main.tsx +9 -0
  35. package/skills/struq/cinematic/examples/concise/01-chapter-spec-example.md +42 -0
  36. package/skills/struq/cinematic/examples/concise/02-handoff-example.md +25 -0
  37. package/skills/struq/cinematic/examples/concise/03-audit-finding-example.md +13 -0
  38. package/skills/struq/cinematic/examples/verbose/01-full-experience-direction.md +687 -0
  39. package/skills/struq/cinematic/examples/verbose/02-full-audit-example.md +362 -0
  40. package/skills/struq/cinematic/references/01-core-mental-model.md +221 -0
  41. package/skills/struq/cinematic/references/02-vocabulary.md +384 -0
  42. package/skills/struq/cinematic/references/03-experience-architecture.md +302 -0
  43. package/skills/struq/cinematic/references/04-timeline-choreography.md +264 -0
  44. package/skills/struq/cinematic/references/05-handoff-contracts.md +241 -0
  45. package/skills/struq/cinematic/references/06-three-rig-architecture.md +286 -0
  46. package/skills/struq/cinematic/references/07-stack-integration.md +228 -0
  47. package/skills/struq/cinematic/references/08-gsap-plugin-patterns.md +218 -0
  48. package/skills/struq/cinematic/references/09-tuning-harness.md +202 -0
  49. package/skills/struq/cinematic/references/10-quality-systems.md +206 -0
  50. package/skills/struq/cinematic/references/11-agent-workflows.md +246 -0
  51. package/skills/struq/cinematic/references/12-audit-rubric.md +176 -0
  52. package/skills/struq/cinematic/references/13-official-docs.md +53 -0
  53. package/skills/struq/cinematic/templates/01-experience-brief.md +105 -0
  54. package/skills/struq/cinematic/templates/02-experience-contract.md +80 -0
  55. package/skills/struq/cinematic/templates/03-chapter-spec.md +103 -0
  56. package/skills/struq/cinematic/templates/04-handoff-contract.md +102 -0
  57. package/skills/struq/cinematic/templates/05-ownership-matrix.md +29 -0
  58. package/skills/struq/cinematic/templates/06-shot-presets.md +59 -0
  59. package/skills/struq/cinematic/templates/07-implementation-slice.md +66 -0
  60. package/skills/struq/cinematic/templates/08-tuning-register.md +36 -0
  61. package/skills/struq/cinematic/templates/09-acceptance-tests.md +57 -0
  62. package/skills/struq/references/anti-patterns.md +265 -0
  63. package/skills/struq/references/baseline-prompt.md +118 -0
  64. package/skills/struq/references/brand.md +143 -0
  65. package/skills/struq/references/brief-router.md +219 -0
  66. package/skills/struq/references/composition.md +191 -0
  67. package/skills/struq/references/depth-parallax.md +235 -0
  68. package/skills/struq/references/direction.md +188 -0
  69. package/skills/struq/references/feel-layer.md +107 -0
  70. package/skills/struq/references/gsap-api.md +293 -0
  71. package/skills/struq/references/iconography.md +255 -0
  72. package/skills/struq/references/imagery.md +144 -0
  73. package/skills/struq/references/loaders.md +287 -0
  74. package/skills/struq/references/micro-interactions.md +270 -0
  75. package/skills/struq/references/mobile-composition.md +70 -0
  76. package/skills/struq/references/nav-header.md +296 -0
  77. package/skills/struq/references/page-architecture.md +134 -0
  78. package/skills/struq/references/page-entrances.md +198 -0
  79. package/skills/struq/references/performance.md +235 -0
  80. package/skills/struq/references/pinning-scrub.md +341 -0
  81. package/skills/struq/references/pitfalls.md +290 -0
  82. package/skills/struq/references/route-transitions.md +281 -0
  83. package/skills/struq/references/scaling.md +151 -0
  84. package/skills/struq/references/scroll-reveals.md +280 -0
  85. package/skills/struq/references/seo.md +219 -0
  86. package/skills/struq/references/setup.md +397 -0
  87. package/skills/struq/references/showcase.md +87 -0
  88. package/skills/struq/references/stack-targets.md +78 -0
  89. package/skills/struq/references/struq-imagery.md +107 -0
  90. package/skills/struq/references/svg-motion.md +390 -0
  91. package/skills/struq/references/transitions.md +160 -0
  92. package/skills/struq/references/triage.md +71 -0
  93. package/skills/struq/references/typography.md +720 -0
  94. package/skills/struq/references/vibe-presets.md +287 -0
  95. package/skills/struq/references/webgl-r3f.md +274 -0
  96. package/skills/struq/references/webgl-scenes.md +593 -0
@@ -0,0 +1,57 @@
1
+ import { useGSAP } from '@gsap/react';
2
+ import { useEffect, useState, type RefObject } from 'react';
3
+ import { buildExperienceTimeline } from './build-experience-timeline';
4
+ import { gsap, registerGsap } from './gsap-client';
5
+ import { experienceRegistry } from './registry';
6
+
7
+ registerGsap();
8
+ if (typeof window !== 'undefined') gsap.registerPlugin(useGSAP);
9
+
10
+ const REQUIRED_TARGETS = [
11
+ 'cameraNarrativeRig',
12
+ 'cameraTargetNarrativeRig',
13
+ 'camera',
14
+ 'subjectNarrativeRig',
15
+ 'subjectReactionRig',
16
+ 'subjectMesh',
17
+ 'interiorRoot',
18
+ 'heroTitle',
19
+ 'bridgePath',
20
+ ] as const;
21
+
22
+ type ExperienceDirectorProps = {
23
+ scopeRef: RefObject<HTMLDivElement | null>;
24
+ narrativeRef: RefObject<HTMLDivElement | null>;
25
+ stageRef: RefObject<HTMLDivElement | null>;
26
+ };
27
+
28
+ export function ExperienceDirector({
29
+ scopeRef,
30
+ narrativeRef,
31
+ stageRef,
32
+ }: ExperienceDirectorProps): null {
33
+ const [registryVersion, setRegistryVersion] = useState(0);
34
+
35
+ useEffect(
36
+ () => experienceRegistry.subscribe(() => setRegistryVersion((value) => value + 1)),
37
+ [],
38
+ );
39
+
40
+ useGSAP(() => {
41
+ if (!narrativeRef.current || !stageRef.current) return;
42
+ if (!experienceRegistry.hasAll([...REQUIRED_TARGETS])) return;
43
+
44
+ const experience = buildExperienceTimeline({
45
+ narrativeElement: narrativeRef.current,
46
+ stageElement: stageRef.current,
47
+ });
48
+
49
+ return experience.cleanup;
50
+ }, {
51
+ scope: scopeRef,
52
+ dependencies: [registryVersion],
53
+ revertOnUpdate: true,
54
+ });
55
+
56
+ return null;
57
+ }
@@ -0,0 +1,37 @@
1
+ import { motion } from 'motion/react';
2
+ import { useEffect, useRef, type JSX } from 'react';
3
+ import { experienceRegistry } from './registry';
4
+
5
+ export function ExperienceOverlay(): JSX.Element {
6
+ const title = useRef<HTMLHeadingElement>(null);
7
+ const path = useRef<SVGPathElement>(null);
8
+
9
+ useEffect(() => {
10
+ const unregister: Array<() => void> = [];
11
+ if (title.current) unregister.push(experienceRegistry.register('heroTitle', title.current));
12
+ if (path.current) unregister.push(experienceRegistry.register('bridgePath', path.current));
13
+ return () => unregister.forEach((dispose) => dispose());
14
+ }, []);
15
+
16
+ return (
17
+ <div className="experience-overlay">
18
+ <div className="experience-copy">
19
+ <h1 ref={title}>One world, continuously directed.</h1>
20
+ <p>Scroll to move through arrival, material, and interior states.</p>
21
+ </div>
22
+
23
+ <svg className="experience-bridge" viewBox="0 0 100 100" aria-hidden="true">
24
+ <path ref={path} d="M10,75 C35,15 65,15 90,75" fill="none" stroke="currentColor" />
25
+ </svg>
26
+
27
+ <motion.button
28
+ className="local-ui-button"
29
+ whileHover={{ scale: 1.04 }}
30
+ whileTap={{ scale: 0.97 }}
31
+ type="button"
32
+ >
33
+ Local Motion interaction
34
+ </motion.button>
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,52 @@
1
+ import { useRef, type JSX } from 'react';
2
+ import { ExperienceCanvas } from './ExperienceCanvas';
3
+ import { ExperienceDirector } from './ExperienceDirector';
4
+ import { ExperienceOverlay } from './ExperienceOverlay';
5
+ import { useExperienceInput } from './useExperienceInput';
6
+ import './styles.css';
7
+
8
+ export function ExperiencePage(): JSX.Element {
9
+ const scopeRef = useRef<HTMLDivElement>(null);
10
+ const narrativeRef = useRef<HTMLDivElement>(null);
11
+ const stageRef = useRef<HTMLDivElement>(null);
12
+
13
+ useExperienceInput();
14
+
15
+ return (
16
+ <div ref={scopeRef} className="experience-root">
17
+ <div ref={narrativeRef} className="experience-narrative">
18
+ <div ref={stageRef} className="experience-stage">
19
+ <ExperienceCanvas />
20
+ <ExperienceOverlay />
21
+ </div>
22
+
23
+ <div className="experience-scroll-space" aria-hidden="true" />
24
+
25
+ <div className="experience-semantic-content">
26
+ <section aria-labelledby="arrival-heading">
27
+ <h2 id="arrival-heading">Arrival</h2>
28
+ <p>The object is introduced as one stable composition.</p>
29
+ </section>
30
+ <section aria-labelledby="material-heading">
31
+ <h2 id="material-heading">Material</h2>
32
+ <p>The same subject transforms instead of being replaced.</p>
33
+ </section>
34
+ <section aria-labelledby="interior-heading">
35
+ <h2 id="interior-heading">Interior</h2>
36
+ <p>The subject becomes the bridge into a new spatial state.</p>
37
+ </section>
38
+ </div>
39
+ </div>
40
+
41
+ <footer className="experience-footer">
42
+ Normal document flow resumes after the persistent experience.
43
+ </footer>
44
+
45
+ <ExperienceDirector
46
+ scopeRef={scopeRef}
47
+ narrativeRef={narrativeRef}
48
+ stageRef={stageRef}
49
+ />
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,32 @@
1
+ import { useThree } from '@react-three/fiber';
2
+ import { useEffect } from 'react';
3
+ import { gsap, registerGsap } from './gsap-client';
4
+
5
+ registerGsap();
6
+
7
+ /**
8
+ * The single clock. The canvas is mounted with frameloop="never", so R3F does
9
+ * not run its own requestAnimationFrame loop. This component renders one frame
10
+ * per gsap.ticker tick, the same ticker that advances Lenis in LenisRoot.
11
+ *
12
+ * gsap.ticker passes its time in seconds, which is what advance() expects when
13
+ * frameloop is "never". Verify against the installed @react-three/fiber version.
14
+ */
15
+ export function GsapFrameLoop(): null {
16
+ const advance = useThree((state) => state.advance);
17
+ const clock = useThree((state) => state.clock);
18
+
19
+ useEffect(() => {
20
+ const tick = (time: number) => advance(time);
21
+
22
+ // Sync R3F's clock to the ticker before the first tick, or the first frame
23
+ // sees a delta from 0 to gsap.ticker.time and every useFrame consumer jumps.
24
+ clock.elapsedTime = gsap.ticker.time;
25
+ gsap.ticker.add(tick);
26
+ return () => {
27
+ gsap.ticker.remove(tick);
28
+ };
29
+ }, [advance, clock]);
30
+
31
+ return null;
32
+ }
@@ -0,0 +1,30 @@
1
+ import { useEffect, useRef, type JSX } from 'react';
2
+ import * as THREE from 'three';
3
+ import { experienceRegistry } from './registry';
4
+ import { runtime } from './runtime';
5
+ import { useFrame } from '@react-three/fiber';
6
+
7
+ export function InteriorRoot(): JSX.Element {
8
+ const root = useRef<THREE.Group>(null);
9
+
10
+ useEffect(() => {
11
+ if (!root.current) return;
12
+ return experienceRegistry.register('interiorRoot', root.current);
13
+ }, []);
14
+
15
+ useFrame(() => {
16
+ if (!root.current) return;
17
+ root.current.visible = runtime.channels.interiorReveal > 0.001;
18
+ });
19
+
20
+ return (
21
+ <group ref={root} position={[0, 0, -3]} visible={false}>
22
+ {Array.from({ length: 14 }, (_, index) => (
23
+ <mesh key={index} position={[0, 0, -index * 0.55]}>
24
+ <torusGeometry args={[1.5, 0.03, 8, 64]} />
25
+ <meshBasicMaterial transparent opacity={0.28} />
26
+ </mesh>
27
+ ))}
28
+ </group>
29
+ );
30
+ }
@@ -0,0 +1,20 @@
1
+ # Shared Architecture Example
2
+
3
+ Copy this folder to `src/experience` inside either the Next.js or Vite example.
4
+
5
+ This code demonstrates:
6
+
7
+ - one persistent stage,
8
+ - one mutable runtime for high-frequency signals,
9
+ - a named target registry,
10
+ - camera and subject rig separation,
11
+ - GSAP chapter and handoff timelines,
12
+ - ScrollTrigger mapping,
13
+ - Lenis integration in the framework adapters,
14
+ - the single clock: Lenis (`autoRaf: false`) and the R3F canvas (`frameloop="never"`, advanced by `GsapFrameLoop`) both run from `gsap.ticker`,
15
+ - Motion used only for local interface behavior,
16
+ - centralized direction values.
17
+
18
+ It is intentionally visually plain. The point is to prove orchestration and ownership before adding production assets, shaders, post-processing, or detailed styling.
19
+
20
+ Verify plugin imports against the GSAP version installed in the project. The examples use the modern `motion/react` import path documented by Motion for React.
@@ -0,0 +1,58 @@
1
+ import { useFrame } from '@react-three/fiber';
2
+ import { useEffect, useRef, type JSX } from 'react';
3
+ import * as THREE from 'three';
4
+ import { experienceRegistry } from './registry';
5
+ import { runtime } from './runtime';
6
+ import { damp } from './math';
7
+
8
+ export function SubjectRig(): JSX.Element {
9
+ const narrativeRig = useRef<THREE.Group>(null);
10
+ const reactionRig = useRef<THREE.Group>(null);
11
+ const mesh = useRef<THREE.Mesh>(null);
12
+
13
+ useEffect(() => {
14
+ if (!narrativeRig.current || !reactionRig.current || !mesh.current) return;
15
+
16
+ const unregister = [
17
+ experienceRegistry.register('subjectNarrativeRig', narrativeRig.current),
18
+ experienceRegistry.register('subjectReactionRig', reactionRig.current),
19
+ experienceRegistry.register('subjectMesh', mesh.current),
20
+ ];
21
+
22
+ return () => unregister.forEach((dispose) => dispose());
23
+ }, []);
24
+
25
+ useFrame((state, delta) => {
26
+ if (!reactionRig.current) return;
27
+
28
+ const interactionAllowed = runtime.activeHandoff === null;
29
+ const targetYaw = interactionAllowed ? runtime.pointer.x * 0.06 : 0;
30
+ const targetPitch = interactionAllowed ? -runtime.pointer.y * 0.03 : 0;
31
+
32
+ reactionRig.current.rotation.y = damp(
33
+ reactionRig.current.rotation.y,
34
+ targetYaw,
35
+ 7,
36
+ delta,
37
+ );
38
+ reactionRig.current.rotation.x = damp(
39
+ reactionRig.current.rotation.x,
40
+ targetPitch,
41
+ 7,
42
+ delta,
43
+ );
44
+
45
+ reactionRig.current.position.y = Math.sin(state.clock.elapsedTime * 0.7) * 0.015;
46
+ });
47
+
48
+ return (
49
+ <group ref={narrativeRig} position={[0.55, -0.1, 0]} rotation={[0.08, -0.55, 0]}>
50
+ <group ref={reactionRig}>
51
+ <mesh ref={mesh}>
52
+ <torusKnotGeometry args={[0.8, 0.24, 160, 24]} />
53
+ <meshStandardMaterial metalness={0.8} roughness={0.2} />
54
+ </mesh>
55
+ </group>
56
+ </group>
57
+ );
58
+ }
@@ -0,0 +1,237 @@
1
+ import type * as THREE from 'three';
2
+ import { experienceDirection, shots, type Shot } from './direction';
3
+ import {
4
+ DrawSVGPlugin,
5
+ ScrollTrigger,
6
+ SplitText,
7
+ gsap,
8
+ registerGsap,
9
+ } from './gsap-client';
10
+ import { experienceRegistry } from './registry';
11
+ import { runtime } from './runtime';
12
+
13
+ export type BuildExperienceTimelineArgs = {
14
+ narrativeElement: HTMLElement;
15
+ stageElement: HTMLElement;
16
+ };
17
+
18
+ function addShot(
19
+ timeline: gsap.core.Timeline,
20
+ shot: Shot,
21
+ position: gsap.Position,
22
+ duration: number,
23
+ ): void {
24
+ const cameraRig = experienceRegistry.require('cameraNarrativeRig');
25
+ const targetRig = experienceRegistry.require('cameraTargetNarrativeRig');
26
+ const camera = experienceRegistry.require('camera');
27
+ const subjectRig = experienceRegistry.require('subjectNarrativeRig');
28
+
29
+ timeline.to(cameraRig.position, {
30
+ x: shot.camera.position[0],
31
+ y: shot.camera.position[1],
32
+ z: shot.camera.position[2],
33
+ duration,
34
+ ease: 'power2.inOut',
35
+ }, position);
36
+
37
+ timeline.to(targetRig.position, {
38
+ x: shot.camera.target[0],
39
+ y: shot.camera.target[1],
40
+ z: shot.camera.target[2],
41
+ duration,
42
+ ease: 'power2.inOut',
43
+ }, position);
44
+
45
+ timeline.to(camera, {
46
+ fov: shot.camera.fov,
47
+ duration,
48
+ ease: 'power1.inOut',
49
+ onUpdate: () => camera.updateProjectionMatrix(),
50
+ }, position);
51
+
52
+ timeline.to(subjectRig.position, {
53
+ x: shot.subject.position[0],
54
+ y: shot.subject.position[1],
55
+ z: shot.subject.position[2],
56
+ duration,
57
+ ease: 'power2.inOut',
58
+ }, position);
59
+
60
+ timeline.to(subjectRig.rotation, {
61
+ x: shot.subject.rotation[0],
62
+ y: shot.subject.rotation[1],
63
+ z: shot.subject.rotation[2],
64
+ duration,
65
+ ease: 'power2.inOut',
66
+ }, position);
67
+
68
+ timeline.to(subjectRig.scale, {
69
+ x: shot.subject.scale,
70
+ y: shot.subject.scale,
71
+ z: shot.subject.scale,
72
+ duration,
73
+ ease: 'power2.inOut',
74
+ }, position);
75
+ }
76
+
77
+ function buildArrival(): { timeline: gsap.core.Timeline; cleanup: () => void } {
78
+ const title = experienceRegistry.require('heroTitle');
79
+ const path = experienceRegistry.require('bridgePath');
80
+ const split = SplitText.create(title, { type: 'lines', mask: 'lines' });
81
+
82
+ const timeline = gsap.timeline({ defaults: { ease: 'none' } });
83
+ timeline.addLabel('arrival:enter', 0)
84
+ .from(split.lines, {
85
+ yPercent: 110,
86
+ autoAlpha: 0,
87
+ duration: 0.28,
88
+ stagger: 0.04,
89
+ ease: 'power3.out',
90
+ }, 0.08)
91
+ .fromTo(path, { drawSVG: '0%' }, {
92
+ drawSVG: '65%',
93
+ duration: 0.48,
94
+ ease: 'power2.inOut',
95
+ }, 0.22)
96
+ .addLabel('arrival:hero', 0.48)
97
+ .addLabel('arrival:handoff-armed', 0.82);
98
+
99
+ addShot(timeline, shots.material, 0.54, 0.46);
100
+
101
+ return { timeline, cleanup: () => split.revert() };
102
+ }
103
+
104
+ function buildMaterial(): gsap.core.Timeline {
105
+ const subject = experienceRegistry.require('subjectNarrativeRig');
106
+ const timeline = gsap.timeline({ defaults: { ease: 'none' } });
107
+
108
+ timeline.addLabel('material:enter', 0)
109
+ .to(subject.rotation, {
110
+ y: 0,
111
+ duration: 0.45,
112
+ ease: 'power2.inOut',
113
+ }, 0.10)
114
+ .to(subject.scale, {
115
+ x: 1.18,
116
+ y: 1.18,
117
+ z: 1.18,
118
+ duration: 0.34,
119
+ ease: 'power2.in',
120
+ }, 0.60)
121
+ .addLabel('portal:armed', 0.74);
122
+
123
+ addShot(timeline, shots.portal, 0.56, 0.44);
124
+
125
+ return timeline;
126
+ }
127
+
128
+ function buildPortalHandoff(): gsap.core.Timeline {
129
+ const interior = experienceRegistry.require('interiorRoot');
130
+ const subject = experienceRegistry.require('subjectNarrativeRig');
131
+ const timeline = gsap.timeline({ defaults: { ease: 'none' } });
132
+
133
+ timeline.addLabel('handoff:enter', 0)
134
+ .to(runtime.channels, {
135
+ pointerCameraStrength: experienceDirection.pointerStrength.critical,
136
+ duration: 0.18,
137
+ ease: 'power2.out',
138
+ }, 0)
139
+ .set(interior, { visible: true }, 0.22)
140
+ .to(runtime.channels, {
141
+ interiorReveal: 1,
142
+ duration: 0.58,
143
+ ease: 'power1.inOut',
144
+ }, 0.22)
145
+ .to(subject.scale, {
146
+ x: runtime.viewport === 'mobile'
147
+ ? experienceDirection.mobile.portalScale
148
+ : 2.1,
149
+ y: runtime.viewport === 'mobile'
150
+ ? experienceDirection.mobile.portalScale
151
+ : 2.1,
152
+ z: runtime.viewport === 'mobile'
153
+ ? experienceDirection.mobile.portalScale
154
+ : 2.1,
155
+ duration: 0.72,
156
+ ease: 'power2.in',
157
+ }, 0)
158
+ .addLabel('cover:safe', 0.68)
159
+ .set(subject, { visible: false }, 0.72)
160
+ .to(runtime.channels, {
161
+ pointerCameraStrength: experienceDirection.pointerStrength.interior,
162
+ duration: 0.22,
163
+ ease: 'power2.out',
164
+ }, 0.76)
165
+ .addLabel('handoff:resolved', 1);
166
+
167
+ addShot(timeline, shots.interior, 0.54, 0.46);
168
+
169
+ return timeline;
170
+ }
171
+
172
+ export function buildExperienceTimeline({
173
+ narrativeElement,
174
+ stageElement,
175
+ }: BuildExperienceTimelineArgs): {
176
+ timeline: gsap.core.Timeline;
177
+ trigger: ScrollTrigger;
178
+ cleanup: () => void;
179
+ } {
180
+ registerGsap();
181
+
182
+ const master = gsap.timeline({ paused: true });
183
+ const arrival = buildArrival();
184
+ const material = buildMaterial();
185
+ const portal = buildPortalHandoff();
186
+
187
+ master
188
+ .addLabel('chapter:arrival', 0)
189
+ .add(arrival.timeline, 'chapter:arrival')
190
+ .addLabel('chapter:material', 1)
191
+ .add(material, 'chapter:material')
192
+ .addLabel('handoff:portal', 2)
193
+ .add(portal, 'handoff:portal')
194
+ .addLabel('chapter:interior', 3);
195
+
196
+ const trigger = ScrollTrigger.create({
197
+ trigger: narrativeElement,
198
+ start: 'top top',
199
+ end: 'bottom bottom',
200
+ pin: stageElement,
201
+ pinSpacing: false,
202
+ // Lenis smooths the scroll input and a numeric scrub smooths the output on
203
+ // top. Keep the two paired (lower Lenis lerp, lower scrub number).
204
+ scrub: 0.65,
205
+ animation: master,
206
+ invalidateOnRefresh: true,
207
+ onUpdate(self) {
208
+ runtime.globalProgress = self.progress;
209
+ runtime.scrollVelocity = self.getVelocity();
210
+ runtime.scrollDirection = self.direction as -1 | 1;
211
+
212
+ if (self.progress < 1 / 3) {
213
+ runtime.activeChapter = 'arrival';
214
+ runtime.activeHandoff = null;
215
+ runtime.chapterProgress = self.progress * 3;
216
+ } else if (self.progress < 2 / 3) {
217
+ runtime.activeChapter = 'material';
218
+ runtime.activeHandoff = null;
219
+ runtime.chapterProgress = (self.progress - 1 / 3) * 3;
220
+ } else {
221
+ runtime.activeChapter = 'interior';
222
+ runtime.activeHandoff = 'material-interior';
223
+ runtime.chapterProgress = (self.progress - 2 / 3) * 3;
224
+ }
225
+ },
226
+ });
227
+
228
+ return {
229
+ timeline: master,
230
+ trigger,
231
+ cleanup: () => {
232
+ trigger.kill();
233
+ master.kill();
234
+ arrival.cleanup();
235
+ },
236
+ };
237
+ }
@@ -0,0 +1,52 @@
1
+ export type Shot = {
2
+ camera: {
3
+ position: [number, number, number];
4
+ target: [number, number, number];
5
+ fov: number;
6
+ };
7
+ subject: {
8
+ position: [number, number, number];
9
+ rotation: [number, number, number];
10
+ scale: number;
11
+ };
12
+ };
13
+
14
+ export const shots: Record<'arrival' | 'material' | 'portal' | 'interior', Shot> = {
15
+ arrival: {
16
+ camera: { position: [0, 0.25, 5.8], target: [0, 0, 0], fov: 34 },
17
+ subject: { position: [0.55, -0.1, 0], rotation: [0.08, -0.55, 0], scale: 1 },
18
+ },
19
+ material: {
20
+ camera: { position: [-0.4, 0.35, 4.4], target: [0.1, 0, 0], fov: 36 },
21
+ subject: { position: [0.15, -0.05, 0], rotation: [0.05, -0.2, 0], scale: 0.95 },
22
+ },
23
+ portal: {
24
+ camera: { position: [0, 0.05, 2.25], target: [0, 0, 0], fov: 29 },
25
+ subject: { position: [0, 0, 0], rotation: [0, 0, 0], scale: 1.3 },
26
+ },
27
+ interior: {
28
+ camera: { position: [0, 0, 0.55], target: [0, 0, -3], fov: 46 },
29
+ subject: { position: [0, 0, -2], rotation: [0, 0, 0], scale: 1 },
30
+ },
31
+ };
32
+
33
+ export const experienceDirection = {
34
+ scrollVh: 10,
35
+ labels: {
36
+ arrivalStart: 0,
37
+ materialStart: 1,
38
+ portalStart: 2,
39
+ interiorStart: 3,
40
+ end: 4,
41
+ },
42
+ pointerStrength: {
43
+ arrival: 0.035,
44
+ material: 0.05,
45
+ critical: 0.008,
46
+ interior: 0.02,
47
+ },
48
+ mobile: {
49
+ portalScale: 1.55,
50
+ pointerMultiplier: 0,
51
+ },
52
+ };
@@ -0,0 +1,32 @@
1
+ import gsap from 'gsap';
2
+ import { ScrollTrigger } from 'gsap/ScrollTrigger';
3
+ import { SplitText } from 'gsap/SplitText';
4
+ import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin';
5
+ import { MorphSVGPlugin } from 'gsap/MorphSVGPlugin';
6
+ import { MotionPathPlugin } from 'gsap/MotionPathPlugin';
7
+
8
+ let registered = false;
9
+
10
+ export function registerGsap(): typeof gsap {
11
+ if (typeof window !== 'undefined' && !registered) {
12
+ gsap.registerPlugin(
13
+ ScrollTrigger,
14
+ SplitText,
15
+ DrawSVGPlugin,
16
+ MorphSVGPlugin,
17
+ MotionPathPlugin,
18
+ );
19
+ registered = true;
20
+ }
21
+
22
+ return gsap;
23
+ }
24
+
25
+ export {
26
+ gsap,
27
+ ScrollTrigger,
28
+ SplitText,
29
+ DrawSVGPlugin,
30
+ MorphSVGPlugin,
31
+ MotionPathPlugin,
32
+ };
@@ -0,0 +1,12 @@
1
+ export function damp(
2
+ current: number,
3
+ target: number,
4
+ lambda: number,
5
+ deltaTime: number,
6
+ ): number {
7
+ return target + (current - target) * Math.exp(-lambda * deltaTime);
8
+ }
9
+
10
+ export function clamp(value: number, min: number, max: number): number {
11
+ return Math.min(max, Math.max(min, value));
12
+ }
@@ -0,0 +1,58 @@
1
+ import type * as THREE from 'three';
2
+
3
+ export type ExperienceTargetMap = {
4
+ cameraNarrativeRig: THREE.Group;
5
+ cameraTargetNarrativeRig: THREE.Group;
6
+ camera: THREE.PerspectiveCamera;
7
+ subjectNarrativeRig: THREE.Group;
8
+ subjectReactionRig: THREE.Group;
9
+ subjectMesh: THREE.Mesh;
10
+ interiorRoot: THREE.Group;
11
+ heroTitle: HTMLElement;
12
+ bridgePath: SVGPathElement;
13
+ };
14
+
15
+ type Key = keyof ExperienceTargetMap;
16
+ type Listener = () => void;
17
+
18
+ class ExperienceRegistry {
19
+ private targets = new Map<Key, ExperienceTargetMap[Key]>();
20
+ private listeners = new Set<Listener>();
21
+
22
+ register<K extends Key>(key: K, value: ExperienceTargetMap[K]): () => void {
23
+ this.targets.set(key, value);
24
+ this.emit();
25
+
26
+ return () => {
27
+ if (this.targets.get(key) === value) {
28
+ this.targets.delete(key);
29
+ this.emit();
30
+ }
31
+ };
32
+ }
33
+
34
+ get<K extends Key>(key: K): ExperienceTargetMap[K] | undefined {
35
+ return this.targets.get(key) as ExperienceTargetMap[K] | undefined;
36
+ }
37
+
38
+ require<K extends Key>(key: K): ExperienceTargetMap[K] {
39
+ const value = this.get(key);
40
+ if (!value) throw new Error(`Missing experience target: ${key}`);
41
+ return value;
42
+ }
43
+
44
+ hasAll(keys: Key[]): boolean {
45
+ return keys.every((key) => this.targets.has(key));
46
+ }
47
+
48
+ subscribe(listener: Listener): () => void {
49
+ this.listeners.add(listener);
50
+ return () => this.listeners.delete(listener);
51
+ }
52
+
53
+ private emit(): void {
54
+ for (const listener of this.listeners) listener();
55
+ }
56
+ }
57
+
58
+ export const experienceRegistry = new ExperienceRegistry();