chati-dev 4.2.2 → 4.3.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/README.md +80 -53
- package/bin/chati.js +170 -5
- package/framework/agents/build/dev.md +509 -76
- package/framework/agents/deploy/devops.md +40 -25
- package/framework/agents/discover/brief.md +158 -22
- package/framework/agents/discover/brownfield-wu.md +26 -14
- package/framework/agents/discover/greenfield-wu.md +102 -14
- package/framework/agents/plan/architect-data-engineer.md +6 -6
- package/framework/agents/plan/architect-system.md +46 -12
- package/framework/agents/plan/architect.md +40 -20
- package/framework/agents/plan/detail.md +38 -24
- package/framework/agents/plan/phases.md +36 -26
- package/framework/agents/plan/tasks.md +114 -33
- package/framework/agents/plan/ux-brand-architect.md +240 -8
- package/framework/agents/plan/ux-component-engineer.md +28 -13
- package/framework/agents/plan/ux-researcher.md +7 -6
- package/framework/agents/plan/ux.md +55 -22
- package/framework/agents/quality/qa-implementation.md +143 -74
- package/framework/agents/quality/qa-planning.md +115 -42
- package/framework/agents/quality/qa-visual.md +448 -0
- package/framework/agents/shared/visualizer.md +128 -0
- package/framework/config.yaml +7 -6
- package/framework/constitution.md +127 -44
- package/framework/context/governance.md +12 -7
- package/framework/context/quality.md +6 -5
- package/framework/context/root.md +6 -6
- package/framework/data/entity-registry.yaml +377 -4
- package/framework/data/model-limits.json +19 -0
- package/framework/domains/agents/qa-visual.yaml +74 -0
- package/framework/domains/constitution.yaml +46 -2
- package/framework/domains/workflows/greenfield-fullstack.yaml +2 -2
- package/framework/hooks/advance-trigger.js +131 -0
- package/framework/hooks/brief-validator.js +83 -0
- package/framework/hooks/constitution-guard.js +24 -5
- package/framework/hooks/license-guard.js +62 -27
- package/framework/hooks/mode-governance.js +13 -2
- package/framework/hooks/model-governance.js +1 -0
- package/framework/hooks/post-dev.js +207 -0
- package/framework/hooks/prism-engine.js +274 -105
- package/framework/hooks/reasoning-escalator.js +371 -0
- package/framework/hooks/reference-trigger.js +117 -0
- package/framework/hooks/session-digest.js +50 -1
- package/framework/hooks/settings.json +32 -1
- package/framework/hooks/style-guard.js +25 -6
- package/framework/hooks/team-quality-gate.js +19 -12
- package/framework/hooks/undercover-guard.js +4 -2
- package/framework/i18n/en.yaml +3 -3
- package/framework/i18n/es.yaml +3 -3
- package/framework/i18n/fr.yaml +3 -3
- package/framework/i18n/pt.yaml +3 -3
- package/framework/intelligence/context-engine.md +4 -5
- package/framework/intelligence/decision-engine.md +1 -1
- package/framework/intelligence/hooks-performance.md +3 -3
- package/framework/migrations/v1.0-to-v1.1.yaml +1 -1
- package/framework/migrations/v1.4-to-v2.0.yaml +11 -11
- package/framework/migrations/v4.0-to-v4.1.yaml +2 -2
- package/framework/migrations/v4.2-to-v4.3.yaml +29 -0
- package/framework/orchestrator/chati-router.js +441 -0
- package/framework/orchestrator/chati-update.md +40 -40
- package/framework/orchestrator/chati.md +295 -94
- package/framework/rules/human-writing-style.md +47 -0
- package/framework/scaffold/motion-premium/README.md +99 -0
- package/framework/scaffold/motion-premium/app/globals.css.template +400 -0
- package/framework/scaffold/motion-premium/app/layout.tsx.template +110 -0
- package/framework/scaffold/motion-premium/components/animation/BackgroundCrossfadeProvider.tsx.template +170 -0
- package/framework/scaffold/motion-premium/components/animation/LenisProvider.tsx.template +49 -0
- package/framework/scaffold/motion-premium/components/animation/PageTransitionWrapper.tsx.template +83 -0
- package/framework/scaffold/motion-premium/components/animation/Preloader.tsx.template +171 -0
- package/framework/scaffold/motion-premium/components/ui/Container.tsx.template +69 -0
- package/framework/scaffold/motion-premium/components/ui/PageSection.tsx.template +74 -0
- package/framework/scaffold/motion-premium/lib/animations/gsap.ts.template +112 -0
- package/framework/scaffold/motion-premium/lib/animations/refreshCoordinator.ts.template +75 -0
- package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +119 -0
- package/framework/scaffold/motion-premium/lib/animations/useGsapContext.ts.template +92 -0
- package/framework/scaffold/motion-premium/lib/animations/useScrollSnapStepper.ts.template +265 -0
- package/framework/scaffold/motion-premium/lib/animations/useSmoothScroll.ts.template +67 -0
- package/framework/scaffold/motion-premium/lib/brand.ts.template +43 -0
- package/framework/scaffold/motion-premium/scaffold.yaml +178 -0
- package/framework/scaffold/motion-premium-3d/README.md +90 -0
- package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoCanvas.tsx.template +81 -0
- package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoClient.tsx.template +75 -0
- package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/page.tsx.template +26 -0
- package/framework/scaffold/motion-premium-3d/components/3d/CameraRig.tsx.template +100 -0
- package/framework/scaffold/motion-premium-3d/components/3d/CanvasProvider.tsx.template +85 -0
- package/framework/scaffold/motion-premium-3d/components/3d/InvalidateOnScroll.tsx.template +51 -0
- package/framework/scaffold/motion-premium-3d/components/3d/MeshCrossfade.tsx.template +79 -0
- package/framework/scaffold/motion-premium-3d/components/3d/ScrollCrossfade.tsx.template +88 -0
- package/framework/scaffold/motion-premium-3d/components/3d/ScrollScene.tsx.template +121 -0
- package/framework/scaffold/motion-premium-3d/components/webgl/SceneFallback.tsx.template +65 -0
- package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +68 -0
- package/framework/scaffold/motion-premium-3d/lib/webgl/detect.ts.template +69 -0
- package/framework/scaffold/motion-premium-3d/scaffold.yaml +136 -0
- package/framework/schemas/session.schema.json +109 -21
- package/framework/scripts/reference-capture.js +430 -0
- package/framework/scripts/visual-qa.js +686 -0
- package/framework/tasks/orchestrator-handoff.md +1 -1
- package/framework/tasks/orchestrator-resume.md +1 -1
- package/framework/tasks/orchestrator-route.md +1 -1
- package/framework/tasks/orchestrator-status.md +3 -3
- package/framework/tasks/qa-planning-gate-define.md +1 -1
- package/framework/templates/brandbook-html-tmpl.md +2 -2
- package/framework/templates/brandbook-tmpl.yaml +1 -1
- package/framework/templates/component-spec-tmpl.yaml +1 -1
- package/framework/templates/design-token-tmpl.yaml +1 -1
- package/framework/templates/icon-system-tmpl.yaml +1 -1
- package/framework/templates/team-planning-tasks.yaml +6 -5
- package/framework/workflows/brownfield-discovery.yaml +2 -2
- package/framework/workflows/brownfield-fullstack.yaml +15 -11
- package/framework/workflows/brownfield-service.yaml +14 -10
- package/framework/workflows/brownfield-ui.yaml +15 -11
- package/framework/workflows/greenfield-fullstack.yaml +16 -13
- package/framework/workflows/quick-flow.yaml +3 -3
- package/framework/workflows/standard-flow.yaml +12 -9
- package/package.json +12 -6
- package/src/autonomy/autonomous-gate.js +1 -0
- package/src/autonomy/build-state.js +1 -2
- package/src/autonomy/progress-reporter.js +1 -1
- package/src/config/agent-customizer.js +11 -3
- package/src/config/claude-settings-generator.js +27 -7
- package/src/config/context-file-generator.js +41 -21
- package/src/config/framework-adapter.js +1 -0
- package/src/config/gemini-hooks-generator.js +19 -7
- package/src/config/mcp-configs.js +1 -0
- package/src/context/layers/l1-global.js +2 -1
- package/src/dashboard/data-reader.js +4 -3
- package/src/dashboard/layout.js +2 -1
- package/src/decision/analyzer.js +6 -30
- package/src/decision/engine.js +4 -28
- package/src/decision/registry-healer.js +3 -2
- package/src/decision/registry-updater.js +23 -14
- package/src/extensions/loader.js +2 -8
- package/src/gates/g1-planning-complete.js +2 -1
- package/src/gates/g2-qa-planning.js +2 -1
- package/src/gates/g3-implementation.js +2 -1
- package/src/gates/g4-qa-implementation.js +3 -2
- package/src/gates/g5-deploy-ready.js +2 -1
- package/src/health/engine.js +4 -3
- package/src/installer/core.js +431 -81
- package/src/installer/preflight.js +131 -0
- package/src/installer/provider-overlay.js +3 -3
- package/src/installer/scaffold-applier.js +358 -0
- package/src/installer/templates.js +46 -29
- package/src/installer/validator.js +17 -12
- package/src/intelligence/registry-manager.js +22 -29
- package/src/intelligence/timeline.js +11 -6
- package/src/license/commands.js +1 -1
- package/src/license/wait.js +102 -0
- package/src/memory/agent-memory.js +81 -0
- package/src/memory/dream.js +32 -1
- package/src/merger/replace-merger.js +28 -15
- package/src/orchestrator/agent-selector.js +2 -1
- package/src/orchestrator/cli.js +1880 -71
- package/src/orchestrator/doctor.js +270 -0
- package/src/orchestrator/handoff-engine.js +4 -3
- package/src/orchestrator/index.js +2 -0
- package/src/orchestrator/pipeline-manager.js +306 -15
- package/src/orchestrator/session-manager.js +339 -6
- package/src/tasks/handoff.js +3 -2
- package/src/telemetry/config.js +4 -3
- package/src/telemetry/schema.js +1 -0
- package/src/terminal/collector.js +3 -2
- package/src/terminal/index.js +1 -2
- package/src/terminal/isolation.js +52 -18
- package/src/terminal/prompt-builder.js +42 -25
- package/src/terminal/run-parallel.js +1 -1
- package/src/terminal/run-team.js +3 -3
- package/src/terminal/team-task-list.js +43 -4
- package/src/upgrade/backup.js +3 -2
- package/src/upgrade/checker.js +3 -2
- package/src/upgrade/migrator.js +65 -7
- package/src/upgrade/tracked-files-detector.js +86 -0
- package/src/upgrade/user-messages.js +94 -0
- package/src/utils/config-parser.js +2 -1
- package/src/utils/feature-flags.js +2 -1
- package/src/utils/flatten-entities.js +69 -0
- package/src/utils/framework-dir.js +16 -0
- package/src/utils/model-id.js +85 -0
- package/src/utils/provider-limits.js +84 -23
- package/src/utils/schema-validator.js +1 -1
- package/src/wizard/i18n.js +5 -4
- package/src/wizard/index.js +14 -0
- package/assets/logo - c/303/263pia.png +0 -0
- package/assets/logo.svg +0 -42
- package/assets/logo2.png +0 -0
- package/assets/social-preview.png +0 -0
- package/scripts/bundle-framework.js +0 -69
- package/scripts/changelog-generator.js +0 -222
- package/scripts/codebase-mapper.js +0 -728
- package/scripts/commit-message-generator.js +0 -167
- package/scripts/coverage-analyzer.js +0 -260
- package/scripts/dependency-analyzer.js +0 -280
- package/scripts/doctor/checks/agents.js +0 -77
- package/scripts/doctor/checks/constitution.js +0 -41
- package/scripts/doctor/checks/domain-alignment.js +0 -58
- package/scripts/doctor/checks/prism-layers.js +0 -84
- package/scripts/doctor/checks/registry.js +0 -55
- package/scripts/doctor/checks/schemas.js +0 -61
- package/scripts/doctor/fixes/reference-fix.js +0 -100
- package/scripts/doctor/fixes/registry-fix.js +0 -56
- package/scripts/doctor/index.js +0 -212
- package/scripts/framework-analyzer.js +0 -308
- package/scripts/generate-constitution-domain.js +0 -253
- package/scripts/generate-signing-key.js +0 -33
- package/scripts/health-check.js +0 -481
- package/scripts/ide-sync.js +0 -326
- package/scripts/performance-analyzer.js +0 -325
- package/scripts/plan-tracker.js +0 -278
- package/scripts/populate-entity-registry.js +0 -481
- package/scripts/pr-review.js +0 -317
- package/scripts/rollback-manager.js +0 -310
- package/scripts/semantic-lint.js +0 -328
- package/scripts/sign-manifest.js +0 -53
- package/scripts/stuck-detector.js +0 -343
- package/scripts/test-quality-assessment.js +0 -257
- package/scripts/validate-agents.js +0 -368
- package/scripts/validate-package.js +0 -505
- package/scripts/validate-tasks.js +0 -465
- package/src/autonomy/worktree-manager.js +0 -250
- package/src/intelligence/decision-engine.js +0 -256
- package/src/intelligence/document-sharder.js +0 -221
- package/src/intelligence/elicitation.js +0 -265
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/animations/gsap.ts — scaffold motion-premium v1.0.0
|
|
3
|
+
*
|
|
4
|
+
* Module-level GSAP setup: plugin registration, Lenis integration, reduced-motion
|
|
5
|
+
* detection. The non-obvious choices here are the difference between "premium"
|
|
6
|
+
* and "broken under real-world conditions" — do not remove without reading why.
|
|
7
|
+
*
|
|
8
|
+
* Non-obvious choices:
|
|
9
|
+
*
|
|
10
|
+
* - `gsap.registerPlugin(ScrollTrigger)` at MODULE level (not inside a hook or
|
|
11
|
+
* component) — this is called exactly once per app load, regardless of HMR
|
|
12
|
+
* reloads or strict-mode double-mounts. Registering inside a component
|
|
13
|
+
* causes a silent warning and then silent failure as GSAP rejects the second
|
|
14
|
+
* registration.
|
|
15
|
+
*
|
|
16
|
+
* - `gsap.ticker.lagSmoothing(0)` — SAUCE PATTERN #1. By default, when the tab
|
|
17
|
+
* loses focus (or the browser throttles a background tab), `requestAnimationFrame`
|
|
18
|
+
* stops firing. GSAP compensates by "smoothing" the accumulated lag the next
|
|
19
|
+
* time RAF fires, time-scaling animations to catch up. For scroll-driven
|
|
20
|
+
* animations this presents as a snap: the page jerks forward as GSAP races to
|
|
21
|
+
* "catch up" scroll-linked progress values. Disabling smoothing keeps
|
|
22
|
+
* progress tied strictly to real scroll position, no catch-up.
|
|
23
|
+
*
|
|
24
|
+
* - Single RAF loop — SAUCE PATTERN #5. Lenis has its own RAF and GSAP has its
|
|
25
|
+
* own ticker; running both causes subtle drift (their clocks diverge over
|
|
26
|
+
* minutes). Hooking Lenis into `gsap.ticker.add` makes GSAP the authoritative
|
|
27
|
+
* clock, Lenis a consumer. Zero drift, one RAF, one heartbeat.
|
|
28
|
+
*
|
|
29
|
+
* - `lenis.raf(time * 1000)` — GSAP ticker passes time in SECONDS, Lenis.raf
|
|
30
|
+
* expects MILLISECONDS. Forgetting this multiplication produces smooth-scroll
|
|
31
|
+
* that runs 1000x slower than expected.
|
|
32
|
+
*
|
|
33
|
+
* - `window.scrollTo(0, 0)` + `lenis.scrollTo(0, immediate: true)` on init —
|
|
34
|
+
* defends against browser's default scrollRestoration on back/forward
|
|
35
|
+
* navigation. Combined with `history.scrollRestoration = 'manual'` in
|
|
36
|
+
* layout.tsx (phase 3), guarantees the page opens at top.
|
|
37
|
+
*
|
|
38
|
+
* - `ScrollTrigger.refresh()` AFTER Lenis init — required because ScrollTrigger
|
|
39
|
+
* measures scroll positions using the native scrollbar by default; once Lenis
|
|
40
|
+
* takes over, all positions must be re-measured against the virtualized
|
|
41
|
+
* scroll surface.
|
|
42
|
+
*
|
|
43
|
+
* - `(window as unknown as {...}).lenis = lenis` — exposes the Lenis/GSAP
|
|
44
|
+
* instances on the global window. Used by the chati-dev visual-qa.js script
|
|
45
|
+
* to call `window.lenis.scrollTo()` during capture runs. Also useful for
|
|
46
|
+
* in-browser debugging. DO NOT remove — regression surface.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
'use client';
|
|
50
|
+
|
|
51
|
+
import { gsap } from 'gsap';
|
|
52
|
+
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
|
53
|
+
|
|
54
|
+
// Register plugins once at module level (see header: registration reason).
|
|
55
|
+
gsap.registerPlugin(ScrollTrigger);
|
|
56
|
+
|
|
57
|
+
export { gsap, ScrollTrigger };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Initialize Lenis smooth scroll and connect it to GSAP + ScrollTrigger.
|
|
61
|
+
* Call this ONCE per app lifecycle from `useSmoothScroll` (LenisProvider).
|
|
62
|
+
* Returns the lenis instance for explicit teardown on unmount.
|
|
63
|
+
*/
|
|
64
|
+
export async function initLenis() {
|
|
65
|
+
const { default: Lenis } = await import('lenis');
|
|
66
|
+
|
|
67
|
+
const lenis = new Lenis({
|
|
68
|
+
duration: 1.2,
|
|
69
|
+
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
|
70
|
+
smoothWheel: true,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (typeof window !== 'undefined') {
|
|
74
|
+
window.scrollTo(0, 0);
|
|
75
|
+
lenis.scrollTo(0, { immediate: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lenis.on('scroll', ScrollTrigger.update);
|
|
79
|
+
|
|
80
|
+
// Single RAF loop — GSAP is the authoritative clock; Lenis consumes it.
|
|
81
|
+
gsap.ticker.add((time) => {
|
|
82
|
+
lenis.raf(time * 1000);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Sauce pattern #1: eliminates tab-restore snap for scroll-driven animations.
|
|
86
|
+
gsap.ticker.lagSmoothing(0);
|
|
87
|
+
|
|
88
|
+
// Refresh ScrollTrigger AFTER Lenis is live so scroll positions are measured
|
|
89
|
+
// against the virtualized surface, not the native scrollbar.
|
|
90
|
+
ScrollTrigger.refresh();
|
|
91
|
+
|
|
92
|
+
// Expose globally for QA scripts (visual-qa.js reads window.lenis for capture).
|
|
93
|
+
if (typeof window !== 'undefined') {
|
|
94
|
+
(window as unknown as { lenis: typeof lenis }).lenis = lenis;
|
|
95
|
+
(window as unknown as { gsap: typeof gsap }).gsap = gsap;
|
|
96
|
+
(window as unknown as { ScrollTrigger: typeof ScrollTrigger }).ScrollTrigger = ScrollTrigger;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return lenis;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if the user has requested reduced motion.
|
|
104
|
+
* SSR-safe: returns false during server render (no `window`).
|
|
105
|
+
*
|
|
106
|
+
* Consumers: useGsapContext (early-return), every animated component that
|
|
107
|
+
* needs a reduced-motion branch.
|
|
108
|
+
*/
|
|
109
|
+
export function prefersReducedMotion(): boolean {
|
|
110
|
+
if (typeof window === 'undefined') return false;
|
|
111
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
112
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/animations/refreshCoordinator.ts — scaffold motion-premium v1.0.0
|
|
3
|
+
*
|
|
4
|
+
* Debounced singleton for ScrollTrigger.refresh(). Coalesces N simultaneous
|
|
5
|
+
* refresh requests from N components mounting together into ONE refresh at
|
|
6
|
+
* the end of the debounce window.
|
|
7
|
+
*
|
|
8
|
+
* Non-obvious choices:
|
|
9
|
+
*
|
|
10
|
+
* - SAUCE PATTERN #9. Multiple components requesting refresh on mount is
|
|
11
|
+
* common: image loaded → want refresh, font loaded → want refresh, video
|
|
12
|
+
* ready → want refresh. Each raw `ScrollTrigger.refresh()` call iterates
|
|
13
|
+
* all registered triggers and recomputes their start/end positions —
|
|
14
|
+
* expensive, and visible as a layout jitter. Debouncing means the browser
|
|
15
|
+
* sees ONE recompute for the burst instead of N.
|
|
16
|
+
*
|
|
17
|
+
* - `DEBOUNCE_MS = 200` — long enough to coalesce typical "everything mounted
|
|
18
|
+
* this frame" bursts (which settle in under 100ms on fast hardware, up to
|
|
19
|
+
* 200ms on slow), short enough that a user will not perceive the delay
|
|
20
|
+
* before layout correctness kicks in.
|
|
21
|
+
*
|
|
22
|
+
* - Module-level `_timer` singleton — the whole point is cross-component
|
|
23
|
+
* coordination. A hook-level timer wouldn't coalesce across siblings.
|
|
24
|
+
*
|
|
25
|
+
* - Dynamic `import('gsap/ScrollTrigger')` inside the debounced fn —
|
|
26
|
+
* keeps this module importable from SSR paths without pulling GSAP into
|
|
27
|
+
* the server bundle. The debounced fn only ever runs client-side (after
|
|
28
|
+
* the `typeof window === 'undefined'` guard in `requestRefresh`).
|
|
29
|
+
*
|
|
30
|
+
* - `cancelPendingRefresh` helper — exposed so page-transition handlers can
|
|
31
|
+
* abort a pending refresh that would otherwise fire during an unmount
|
|
32
|
+
* animation (the refresh rips triggers away that the exit animation is
|
|
33
|
+
* still using). Most callers don't need this; it's a surgical tool.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export const DEBOUNCE_MS = 200;
|
|
37
|
+
|
|
38
|
+
let _timer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Request a ScrollTrigger.refresh(). Multiple calls within DEBOUNCE_MS
|
|
42
|
+
* coalesce into a single refresh at the end of the debounce window.
|
|
43
|
+
*
|
|
44
|
+
* Safe to call from SSR context: no-ops when window is undefined.
|
|
45
|
+
*/
|
|
46
|
+
export function requestRefresh(): void {
|
|
47
|
+
if (typeof window === 'undefined') return;
|
|
48
|
+
|
|
49
|
+
// Cancel any pending refresh
|
|
50
|
+
if (_timer !== null) {
|
|
51
|
+
clearTimeout(_timer);
|
|
52
|
+
_timer = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Schedule a new refresh
|
|
56
|
+
_timer = setTimeout(async () => {
|
|
57
|
+
_timer = null;
|
|
58
|
+
const { ScrollTrigger } = await import('gsap/ScrollTrigger');
|
|
59
|
+
ScrollTrigger.refresh();
|
|
60
|
+
}, DEBOUNCE_MS);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Cancel any pending refresh. Use before a page transition / unmount that
|
|
65
|
+
* should not be perturbed by a trailing refresh.
|
|
66
|
+
*
|
|
67
|
+
* Usually not needed — the refresh is harmless after unmount. Reach for
|
|
68
|
+
* this when you see a visible jump during a page transition.
|
|
69
|
+
*/
|
|
70
|
+
export function cancelPendingRefresh(): void {
|
|
71
|
+
if (_timer !== null) {
|
|
72
|
+
clearTimeout(_timer);
|
|
73
|
+
_timer = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/animations/tokens.ts — scaffold motion-premium v1.0.0
|
|
3
|
+
*
|
|
4
|
+
* Animation constants: easings, durations, staggers, ScrollTrigger defaults.
|
|
5
|
+
* Mirrors the CSS custom properties declared in app/globals.css.
|
|
6
|
+
*
|
|
7
|
+
* Why this file exists separately from globals.css:
|
|
8
|
+
* - GSAP accepts easing as a `cubic-bezier(...)` string OR a function;
|
|
9
|
+
* Framer Motion expects a 4-number array. Having both shapes here prevents
|
|
10
|
+
* every component from re-deriving the same curve math.
|
|
11
|
+
* - GSAP uses seconds for duration; CSS uses milliseconds. Converting at
|
|
12
|
+
* the token boundary is cleaner than at every call site.
|
|
13
|
+
* - Staggers have no CSS equivalent — they live purely in the JS animation
|
|
14
|
+
* API, so the TS file is their canonical home.
|
|
15
|
+
*
|
|
16
|
+
* Override classification: `partial` — adjust values (e.g. change `cinematic`
|
|
17
|
+
* from 1.4s to 1.2s), but keep the exported object shape so downstream
|
|
18
|
+
* templates (useGsapContext consumers, providers) remain compatible.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// -----------------------------------------------------------------------
|
|
22
|
+
// EASINGS — string form for GSAP + CSS
|
|
23
|
+
// Mirror of --ease-* custom properties declared in app/globals.css.
|
|
24
|
+
// When values change in globals.css, mirror here to avoid drift.
|
|
25
|
+
// -----------------------------------------------------------------------
|
|
26
|
+
export const EASINGS = {
|
|
27
|
+
/** --ease-default: cubic-bezier(0.25, 0.1, 0.25, 1) */
|
|
28
|
+
default: "cubic-bezier(0.25, 0.1, 0.25, 1)",
|
|
29
|
+
/** --ease-in: cubic-bezier(0.42, 0, 1, 1) */
|
|
30
|
+
in: "cubic-bezier(0.42, 0, 1, 1)",
|
|
31
|
+
/** --ease-out: cubic-bezier(0, 0, 0.58, 1) */
|
|
32
|
+
out: "cubic-bezier(0, 0, 0.58, 1)",
|
|
33
|
+
/** --ease-in-out: cubic-bezier(0.42, 0, 0.58, 1) */
|
|
34
|
+
inOut: "cubic-bezier(0.42, 0, 0.58, 1)",
|
|
35
|
+
/** --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275) */
|
|
36
|
+
spring: "cubic-bezier(0.175, 0.885, 0.32, 1.275)",
|
|
37
|
+
/** --ease-cinematic: cubic-bezier(0.16, 1, 0.3, 1) */
|
|
38
|
+
cinematic: "cubic-bezier(0.16, 1, 0.3, 1)",
|
|
39
|
+
/** --ease-gentle: cubic-bezier(0.4, 0, 0.2, 1) */
|
|
40
|
+
gentle: "cubic-bezier(0.4, 0, 0.2, 1)",
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export type EasingKey = keyof typeof EASINGS;
|
|
44
|
+
|
|
45
|
+
// -----------------------------------------------------------------------
|
|
46
|
+
// EASING_CURVES — array form for Framer Motion
|
|
47
|
+
// Same curves as EASINGS but expressed as 4-number tuples. Framer Motion's
|
|
48
|
+
// `ease` prop rejects `cubic-bezier(...)` strings, so this parallel shape
|
|
49
|
+
// is required — do not try to collapse into EASINGS.
|
|
50
|
+
// -----------------------------------------------------------------------
|
|
51
|
+
export const EASING_CURVES: Record<EasingKey, [number, number, number, number]> = {
|
|
52
|
+
default: [0.25, 0.1, 0.25, 1],
|
|
53
|
+
in: [0.42, 0, 1, 1],
|
|
54
|
+
out: [0, 0, 0.58, 1],
|
|
55
|
+
inOut: [0.42, 0, 0.58, 1],
|
|
56
|
+
spring: [0.175, 0.885, 0.32, 1.275],
|
|
57
|
+
cinematic: [0.16, 1, 0.3, 1],
|
|
58
|
+
gentle: [0.4, 0, 0.2, 1],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// -----------------------------------------------------------------------
|
|
62
|
+
// DURATIONS — seconds (GSAP convention)
|
|
63
|
+
// The CSS side is in milliseconds; the values here are the same intervals
|
|
64
|
+
// converted at the token boundary. Reference mapping in doc comments.
|
|
65
|
+
// -----------------------------------------------------------------------
|
|
66
|
+
export const DURATIONS = {
|
|
67
|
+
/** --duration-instant: 0ms -> 0s (no-op / CSS-only transitions) */
|
|
68
|
+
instant: 0,
|
|
69
|
+
/** --duration-fast: 150ms -> 0.15s (hover states, microinteractions) */
|
|
70
|
+
fast: 0.15,
|
|
71
|
+
/** --duration-normal: 300ms -> 0.3s (default UI transitions) */
|
|
72
|
+
normal: 0.3,
|
|
73
|
+
/** --duration-slow: 500ms -> 0.5s (panel reveals, accordion) */
|
|
74
|
+
slow: 0.5,
|
|
75
|
+
/** --duration-dramatic: 900ms -> 0.9s (hero entrance, section reveals) */
|
|
76
|
+
dramatic: 0.9,
|
|
77
|
+
/** --duration-cinematic: 1400ms -> 1.4s (long pin scrubs, crossfades) */
|
|
78
|
+
cinematic: 1.4,
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
81
|
+
export type DurationKey = keyof typeof DURATIONS;
|
|
82
|
+
|
|
83
|
+
// -----------------------------------------------------------------------
|
|
84
|
+
// STAGGERS — inter-element delays (seconds)
|
|
85
|
+
// No CSS equivalent — staggers are an animation-API concept. These are the
|
|
86
|
+
// TypeScript source of truth; there is no globals.css mirror.
|
|
87
|
+
// -----------------------------------------------------------------------
|
|
88
|
+
export const STAGGERS = {
|
|
89
|
+
/** Fine-grained character or item stagger (split-text reveals) */
|
|
90
|
+
tight: 0.03,
|
|
91
|
+
/** Default stagger for lists, cards, grids */
|
|
92
|
+
default: 0.05,
|
|
93
|
+
/** Relaxed stagger for section-level reveals */
|
|
94
|
+
relaxed: 0.1,
|
|
95
|
+
/** Dramatic entrance stagger for hero copy */
|
|
96
|
+
hero: 0.15,
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
export type StaggerKey = keyof typeof STAGGERS;
|
|
100
|
+
|
|
101
|
+
// -----------------------------------------------------------------------
|
|
102
|
+
// SCROLL_TRIGGER_DEFAULTS — common trigger start/end strings
|
|
103
|
+
// The GSAP "top 80%" syntax means: "fire when element's top is at 80% down
|
|
104
|
+
// the viewport". Defaults here encode the weight/timing recommendations
|
|
105
|
+
// from the reference implementation (reference-website) — they produce the
|
|
106
|
+
// signature feel of the premium pattern set.
|
|
107
|
+
// -----------------------------------------------------------------------
|
|
108
|
+
export const SCROLL_TRIGGER_DEFAULTS = {
|
|
109
|
+
/** Element enters viewport at 80% from top — standard reveal threshold */
|
|
110
|
+
standardReveal: "top 80%",
|
|
111
|
+
/** Element enters viewport at 90% from top — early reveal for large blocks */
|
|
112
|
+
earlyReveal: "top 90%",
|
|
113
|
+
/** Element enters viewport at 70% from top — late reveal for emphasis */
|
|
114
|
+
lateReveal: "top 70%",
|
|
115
|
+
/** Scrub scene start position (pinned panels) */
|
|
116
|
+
sceneStart: "top top",
|
|
117
|
+
/** Scrub scene end (500% viewport height travel — typical pin duration) */
|
|
118
|
+
sceneEnd: "+=500%",
|
|
119
|
+
} as const;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/animations/useGsapContext.ts — scaffold motion-premium v1.0.0
|
|
3
|
+
*
|
|
4
|
+
* Scoped GSAP animations with automatic cleanup and reduced-motion safety.
|
|
5
|
+
* This is the primary hook every animated component should use.
|
|
6
|
+
*
|
|
7
|
+
* Non-obvious choices:
|
|
8
|
+
*
|
|
9
|
+
* - `gsap.context(fn, containerRef)` — SAUCE PATTERN #3. The second argument
|
|
10
|
+
* SCOPES all selector strings inside `fn` to descendants of containerRef.
|
|
11
|
+
* Calling `gsap.fromTo('.title', ...)` inside the context ONLY matches
|
|
12
|
+
* `.title` elements within this component, not globally. Without scope, a
|
|
13
|
+
* second mount of the same component (or any sibling with .title) would
|
|
14
|
+
* double-animate. Forget this and the bug appears as "animations sometimes
|
|
15
|
+
* jitter or fire twice on some pages" — hell to debug.
|
|
16
|
+
*
|
|
17
|
+
* - `ctx.revert()` on unmount — kills every tween, timeline, and ScrollTrigger
|
|
18
|
+
* created inside the context. Without this, ScrollTriggers leak: they stay
|
|
19
|
+
* registered against the scroll position, firing on pages that don't contain
|
|
20
|
+
* the originating component. Memory cost grows with navigation; worse, the
|
|
21
|
+
* leaked triggers intercept wheel events and break Lenis.
|
|
22
|
+
*
|
|
23
|
+
* - Reduced-motion early-return with `clearProps` — SAUCE PATTERN #11 tie-in.
|
|
24
|
+
* When prefers-reduced-motion is on, we DO NOT run the setup fn (would
|
|
25
|
+
* leave initial state like `opacity: 0` stuck). Instead we forcibly show
|
|
26
|
+
* all [data-gsap-target] descendants at their final state. `clearProps:
|
|
27
|
+
* 'transform,opacity'` strips any prior GSAP overrides so CSS defaults take
|
|
28
|
+
* effect. Components must tag their animated elements with
|
|
29
|
+
* `data-gsap-target` for this fallback to find them.
|
|
30
|
+
*
|
|
31
|
+
* - `deps` param — passed to useEffect. Default empty array = run once,
|
|
32
|
+
* consistent with how most component animations want it. Pass a non-empty
|
|
33
|
+
* array only if the animation genuinely depends on state that can change.
|
|
34
|
+
*
|
|
35
|
+
* - `RefObject<HTMLElement | null>` — the `| null` matches React 19's
|
|
36
|
+
* stricter ref typing where `useRef<HTMLDivElement>(null)` returns a
|
|
37
|
+
* nullable ref. Removing `| null` forces every caller to cast.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
'use client';
|
|
41
|
+
|
|
42
|
+
import { useEffect, useRef } from 'react';
|
|
43
|
+
import { gsap, prefersReducedMotion } from './gsap';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run a GSAP setup inside a scoped context tied to a container ref.
|
|
47
|
+
* Automatically calls `ctx.revert()` on unmount for leak-free teardown.
|
|
48
|
+
*
|
|
49
|
+
* Usage:
|
|
50
|
+
* const containerRef = useRef<HTMLDivElement>(null);
|
|
51
|
+
* useGsapContext(containerRef, (ctx) => {
|
|
52
|
+
* gsap.fromTo('.title', { opacity: 0 }, { opacity: 1 });
|
|
53
|
+
* // Register ScrollTriggers, timelines, etc. They are all scoped.
|
|
54
|
+
* });
|
|
55
|
+
*
|
|
56
|
+
* If reduced-motion is active, `setupFn` is NOT called. Instead, all
|
|
57
|
+
* `[data-gsap-target]` descendants are set to the visible final state.
|
|
58
|
+
* Mark every animated element with `data-gsap-target` so this fallback
|
|
59
|
+
* can find it.
|
|
60
|
+
*/
|
|
61
|
+
export function useGsapContext(
|
|
62
|
+
containerRef: React.RefObject<HTMLElement | null>,
|
|
63
|
+
setupFn: (context: gsap.Context) => void,
|
|
64
|
+
deps: React.DependencyList = []
|
|
65
|
+
) {
|
|
66
|
+
const ctxRef = useRef<gsap.Context | null>(null);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!containerRef.current) return;
|
|
70
|
+
|
|
71
|
+
// Reduced-motion safeguard: skip animations, show targets at final state.
|
|
72
|
+
if (prefersReducedMotion()) {
|
|
73
|
+
const targets = containerRef.current.querySelectorAll('[data-gsap-target]');
|
|
74
|
+
if (targets.length > 0) {
|
|
75
|
+
gsap.set(targets, { opacity: 1, y: 0, clearProps: 'transform,opacity' });
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Scoped context — all selectors inside setupFn match only this subtree.
|
|
81
|
+
ctxRef.current = gsap.context((ctx) => {
|
|
82
|
+
setupFn(ctx);
|
|
83
|
+
}, containerRef);
|
|
84
|
+
|
|
85
|
+
// Cleanup: kill every tween / timeline / ScrollTrigger created above.
|
|
86
|
+
return () => {
|
|
87
|
+
ctxRef.current?.revert();
|
|
88
|
+
ctxRef.current = null;
|
|
89
|
+
};
|
|
90
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
|
+
}, deps);
|
|
92
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/animations/useScrollSnapStepper.ts — scaffold motion-premium v1.0.0
|
|
3
|
+
*
|
|
4
|
+
* Step-snap navigation for horizontally-pinned ScrollTrigger sequences.
|
|
5
|
+
* One wheel tick = one panel advance. Used by multi-stage pins (method
|
|
6
|
+
* sections, editorial lists) where free scrub would miss panels entirely.
|
|
7
|
+
*
|
|
8
|
+
* The function is named `setupScrollSnapStepper` (not a hook) so callers
|
|
9
|
+
* can invoke it from inside `useGsapContext` setup fns without nested hooks.
|
|
10
|
+
*
|
|
11
|
+
* Non-obvious choices:
|
|
12
|
+
*
|
|
13
|
+
* - `normalizeWheelDelta` — WheelEvent.deltaY values differ by deltaMode:
|
|
14
|
+
* 0 = pixels (trackpad, default), 1 = lines (mouse wheel, multiply by 16),
|
|
15
|
+
* 2 = pages (rare, multiply by 800). Without normalization, a mouse wheel
|
|
16
|
+
* tick (deltaY=1 in mode 1) looks smaller than a trackpad flick (deltaY=50
|
|
17
|
+
* in mode 0) and the threshold logic misbehaves.
|
|
18
|
+
*
|
|
19
|
+
* - `wheelAccumulator` with 500ms reset — SAUCE PATTERN #13. A trackpad
|
|
20
|
+
* momentum scroll fires dozens of small deltaY events. Each alone is
|
|
21
|
+
* sub-threshold, but their sum represents clear intent. The accumulator
|
|
22
|
+
* sums deltas; the 500ms reset clears stale totals so a slow continuous
|
|
23
|
+
* scroll doesn't eventually cross the threshold by itself.
|
|
24
|
+
*
|
|
25
|
+
* - `WHEEL_THRESHOLD = 50` — chosen empirically. Low enough that a single
|
|
26
|
+
* deliberate swipe crosses it; high enough that drift/noise doesn't.
|
|
27
|
+
*
|
|
28
|
+
* - `entryCooldownUntil` — SAUCE PATTERN #12. 600ms cooldown after the pin
|
|
29
|
+
* section scrolls INTO view. Without it, the momentum of the entry scroll
|
|
30
|
+
* triggers an immediate first-panel snap — user perceives "I just got here,
|
|
31
|
+
* why is it already moving?" Cooldown owner (caller) sets this via
|
|
32
|
+
* stepState.entryCooldownUntil = performance.now() + 600 on pin start.
|
|
33
|
+
*
|
|
34
|
+
* - `lenis.stop()` / `lenis.start()` around programmatic scroll — Lenis's
|
|
35
|
+
* smoothWheel listener fights user-initiated scrolls that are actually
|
|
36
|
+
* our own programmatic scrollTo. Stopping Lenis prevents the fight; the
|
|
37
|
+
* 150ms post-complete delay before `start()` absorbs any residual input.
|
|
38
|
+
*
|
|
39
|
+
* - `lock: true` on lenis.scrollTo — forbids other scroll inputs until our
|
|
40
|
+
* programmatic motion completes. Without it, a rapid second wheel tick
|
|
41
|
+
* during a snap causes a compound motion that overshoots by 2 panels.
|
|
42
|
+
*
|
|
43
|
+
* - `capture: true` listeners — React's synthetic event system sits downstream
|
|
44
|
+
* of native capture-phase listeners. By listening in capture we intercept
|
|
45
|
+
* wheel/touch BEFORE React handlers (which may preventDefault on us or
|
|
46
|
+
* trigger layout scroll that makes Lenis fight).
|
|
47
|
+
*
|
|
48
|
+
* - `passive: false` on wheel + touchmove — required for preventDefault to
|
|
49
|
+
* work. Chrome warns (correctly) that passive listeners are faster, but we
|
|
50
|
+
* need the ability to cancel the scroll during isBlocked() windows.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
'use client';
|
|
54
|
+
|
|
55
|
+
import { ScrollTrigger } from './gsap';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Minimal Lenis surface we depend on. Kept narrow so the stepper can work
|
|
59
|
+
* with any Lenis-like scroll driver (or a shim in tests).
|
|
60
|
+
*/
|
|
61
|
+
export interface LenisLike {
|
|
62
|
+
stop: () => void;
|
|
63
|
+
start: () => void;
|
|
64
|
+
scrollTo: (
|
|
65
|
+
v: number,
|
|
66
|
+
o?: {
|
|
67
|
+
duration?: number;
|
|
68
|
+
easing?: (t: number) => number;
|
|
69
|
+
force?: boolean;
|
|
70
|
+
onComplete?: () => void;
|
|
71
|
+
lock?: boolean;
|
|
72
|
+
},
|
|
73
|
+
) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Retrieve the globally-exposed Lenis instance (see gsap.ts initLenis).
|
|
78
|
+
* Returns undefined during SSR or before LenisProvider has mounted.
|
|
79
|
+
*/
|
|
80
|
+
export function getLenis(): LenisLike | undefined {
|
|
81
|
+
if (typeof window === 'undefined') return undefined;
|
|
82
|
+
const w = window as unknown as { lenis?: LenisLike };
|
|
83
|
+
return w.lenis;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Mutable state the stepper threads between invocations. Owners typically
|
|
88
|
+
* allocate one per pinned ScrollTrigger instance.
|
|
89
|
+
*/
|
|
90
|
+
export interface StepState {
|
|
91
|
+
isSnapping: boolean;
|
|
92
|
+
lastScrollTs: number;
|
|
93
|
+
entryCooldownUntil: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface StepByStepConfig {
|
|
97
|
+
/** ScrollTrigger id that this stepper should advance. */
|
|
98
|
+
triggerId: string;
|
|
99
|
+
/** Number of discrete stages (panels) within the scrubbed range. */
|
|
100
|
+
stageCount: number;
|
|
101
|
+
/** Mutable step state (snap in progress, cooldowns, last wheel ts). */
|
|
102
|
+
stepState: StepState;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Attach wheel + touch listeners that advance a scrubbed ScrollTrigger one
|
|
107
|
+
* panel at a time. Returns a cleanup function that removes the listeners;
|
|
108
|
+
* always pair with useEffect or gsap.context cleanup.
|
|
109
|
+
*
|
|
110
|
+
* Typical call site:
|
|
111
|
+
* useGsapContext(sectionRef, () => {
|
|
112
|
+
* const stepState = { isSnapping: false, lastScrollTs: 0, entryCooldownUntil: 0 };
|
|
113
|
+
* ScrollTrigger.create({ id: 'method', trigger: ..., pin: true, ... });
|
|
114
|
+
* const cleanup = setupScrollSnapStepper({
|
|
115
|
+
* triggerId: 'method', stageCount: 5, stepState,
|
|
116
|
+
* });
|
|
117
|
+
* return cleanup; // gsap.context.revert() calls this for free if returned
|
|
118
|
+
* });
|
|
119
|
+
*/
|
|
120
|
+
export function setupScrollSnapStepper({
|
|
121
|
+
triggerId,
|
|
122
|
+
stageCount,
|
|
123
|
+
stepState,
|
|
124
|
+
}: StepByStepConfig): () => void {
|
|
125
|
+
const stageStep = 1 / (stageCount - 1);
|
|
126
|
+
let touchStartY = 0;
|
|
127
|
+
|
|
128
|
+
const advance = (direction: number): boolean => {
|
|
129
|
+
if (stepState.isSnapping) return true;
|
|
130
|
+
const st = ScrollTrigger.getById(triggerId);
|
|
131
|
+
if (!st || !st.isActive) return false;
|
|
132
|
+
|
|
133
|
+
const currentStep = Math.round(st.progress / stageStep);
|
|
134
|
+
const nextStep = currentStep + direction;
|
|
135
|
+
|
|
136
|
+
if (nextStep < 0 || nextStep > stageCount - 1) return false;
|
|
137
|
+
|
|
138
|
+
stepState.isSnapping = true;
|
|
139
|
+
const lenis = getLenis();
|
|
140
|
+
if (lenis) lenis.stop();
|
|
141
|
+
|
|
142
|
+
const targetProgress = nextStep * stageStep;
|
|
143
|
+
const targetScroll = st.start + (st.end - st.start) * targetProgress;
|
|
144
|
+
|
|
145
|
+
const finish = () => {
|
|
146
|
+
window.setTimeout(() => {
|
|
147
|
+
stepState.isSnapping = false;
|
|
148
|
+
stepState.lastScrollTs = performance.now();
|
|
149
|
+
if (lenis) lenis.start();
|
|
150
|
+
}, 150);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (lenis) {
|
|
154
|
+
lenis.scrollTo(targetScroll, {
|
|
155
|
+
duration: 0.9,
|
|
156
|
+
easing: (t: number) => 1 - Math.pow(1 - t, 3),
|
|
157
|
+
force: true,
|
|
158
|
+
lock: true,
|
|
159
|
+
onComplete: finish,
|
|
160
|
+
});
|
|
161
|
+
window.setTimeout(finish, 1100);
|
|
162
|
+
} else {
|
|
163
|
+
window.scrollTo({ top: targetScroll, behavior: 'smooth' });
|
|
164
|
+
window.setTimeout(finish, 900);
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const isBlocked = () => {
|
|
170
|
+
const now = performance.now();
|
|
171
|
+
return (
|
|
172
|
+
stepState.isSnapping ||
|
|
173
|
+
now < stepState.entryCooldownUntil ||
|
|
174
|
+
now - stepState.lastScrollTs < 200
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Normalize WheelEvent deltaY across deltaMode values:
|
|
179
|
+
// 0 = pixels (pass-through), 1 = lines (*16), 2 = pages (*800).
|
|
180
|
+
const normalizeWheelDelta = (e: WheelEvent): number => {
|
|
181
|
+
if (e.deltaMode === 1) return e.deltaY * 16;
|
|
182
|
+
if (e.deltaMode === 2) return e.deltaY * 800;
|
|
183
|
+
return e.deltaY;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
let wheelAccumulator = 0;
|
|
187
|
+
let wheelResetTimer: ReturnType<typeof setTimeout> | null = null;
|
|
188
|
+
const WHEEL_THRESHOLD = 50;
|
|
189
|
+
|
|
190
|
+
const onWheel = (e: WheelEvent) => {
|
|
191
|
+
const st = ScrollTrigger.getById(triggerId);
|
|
192
|
+
if (!st || !st.isActive) return;
|
|
193
|
+
|
|
194
|
+
const normalized = normalizeWheelDelta(e);
|
|
195
|
+
if (Math.abs(normalized) < 1) return;
|
|
196
|
+
|
|
197
|
+
if (isBlocked()) {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
e.stopPropagation();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Accumulate normalized delta; reset after 500ms of no events.
|
|
204
|
+
wheelAccumulator += normalized;
|
|
205
|
+
if (wheelResetTimer !== null) clearTimeout(wheelResetTimer);
|
|
206
|
+
wheelResetTimer = setTimeout(() => {
|
|
207
|
+
wheelAccumulator = 0;
|
|
208
|
+
wheelResetTimer = null;
|
|
209
|
+
}, 500);
|
|
210
|
+
|
|
211
|
+
if (Math.abs(wheelAccumulator) < WHEEL_THRESHOLD) {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
e.stopPropagation();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Threshold exceeded: fire step-snap and reset accumulator.
|
|
218
|
+
const direction = wheelAccumulator > 0 ? 1 : -1;
|
|
219
|
+
wheelAccumulator = 0;
|
|
220
|
+
if (wheelResetTimer !== null) {
|
|
221
|
+
clearTimeout(wheelResetTimer);
|
|
222
|
+
wheelResetTimer = null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const handled = advance(direction);
|
|
226
|
+
if (handled) {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
e.stopPropagation();
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const onTouchStart = (e: TouchEvent) => {
|
|
233
|
+
touchStartY = e.touches[0].clientY;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const onTouchMove = (e: TouchEvent) => {
|
|
237
|
+
const st = ScrollTrigger.getById(triggerId);
|
|
238
|
+
if (!st || !st.isActive) return;
|
|
239
|
+
if (isBlocked()) {
|
|
240
|
+
e.preventDefault();
|
|
241
|
+
e.stopPropagation();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// Screen-origin inverted: finger moves DOWN (deltaY > 0 here) = forward scroll.
|
|
245
|
+
const deltaY = touchStartY - e.touches[0].clientY;
|
|
246
|
+
if (Math.abs(deltaY) < 25) return;
|
|
247
|
+
const direction = deltaY > 0 ? 1 : -1;
|
|
248
|
+
const handled = advance(direction);
|
|
249
|
+
if (handled) {
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
e.stopPropagation();
|
|
252
|
+
touchStartY = e.touches[0].clientY;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
|
257
|
+
window.addEventListener('touchstart', onTouchStart, { passive: true, capture: true });
|
|
258
|
+
window.addEventListener('touchmove', onTouchMove, { passive: false, capture: true });
|
|
259
|
+
|
|
260
|
+
return () => {
|
|
261
|
+
window.removeEventListener('wheel', onWheel, { capture: true } as EventListenerOptions);
|
|
262
|
+
window.removeEventListener('touchstart', onTouchStart, { capture: true } as EventListenerOptions);
|
|
263
|
+
window.removeEventListener('touchmove', onTouchMove, { capture: true } as EventListenerOptions);
|
|
264
|
+
};
|
|
265
|
+
}
|