chati-dev 4.2.2 → 4.3.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/README.md +80 -53
- package/bin/chati.js +150 -5
- package/framework/agents/build/dev.md +509 -76
- package/framework/agents/deploy/devops.md +40 -25
- package/framework/agents/discover/brief.md +156 -22
- package/framework/agents/discover/brownfield-wu.md +24 -14
- package/framework/agents/discover/greenfield-wu.md +100 -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 +36 -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 +439 -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 +387 -0
- package/framework/orchestrator/chati-update.md +40 -40
- package/framework/orchestrator/chati.md +294 -94
- package/framework/scaffold/motion-premium/README.md +89 -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 +174 -0
- package/framework/scaffold/motion-premium-3d/README.md +80 -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 +133 -0
- package/framework/schemas/session.schema.json +109 -21
- package/framework/scripts/reference-capture.js +430 -0
- package/framework/scripts/visual-qa.js +674 -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 +1 -1
- 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 +10 -5
- 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 +422 -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 +1869 -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 +331 -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,85 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CanvasProvider — the ONLY <Canvas> mount point in the app.
|
|
5
|
+
*
|
|
6
|
+
* Design invariants (ADR-3D-01 through ADR-3D-04):
|
|
7
|
+
*
|
|
8
|
+
* 1) Client-only mount. The module is imported through a
|
|
9
|
+
* next/dynamic({ ssr: false }) wrapper at the page boundary, so
|
|
10
|
+
* Three.js / R3F code never executes on the server.
|
|
11
|
+
*
|
|
12
|
+
* 2) Single RAF. <Canvas frameloop="demand"> disables R3F's default
|
|
13
|
+
* always-on 60FPS loop. Rendering happens ONLY when a component
|
|
14
|
+
* calls invalidate() — InvalidateOnScroll (see sibling file) ties
|
|
15
|
+
* that to the Lenis/GSAP master tick established by motion-premium's
|
|
16
|
+
* ADR-LENIS-GSAP. Zero competing RAFs across the whole app.
|
|
17
|
+
*
|
|
18
|
+
* 3) WebGL gate. If WebGLContext reports unavailable (no WebGL OR
|
|
19
|
+
* prefers-reduced-motion), the Canvas never mounts and the caller's
|
|
20
|
+
* <fallback> prop renders in its place.
|
|
21
|
+
*
|
|
22
|
+
* 4) Responsible defaults for premium sites:
|
|
23
|
+
* dpr: [1, 2] — Retina-aware without pathological 3x scaling
|
|
24
|
+
* gl.antialias: true — crisp lines matter at scroll speeds
|
|
25
|
+
* gl.alpha: true — canvas blends with page background
|
|
26
|
+
* gl.powerPreference: 'high-performance'
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* <CanvasProvider fallback={<SceneFallback fullBleed />}>
|
|
30
|
+
* <CameraRig />
|
|
31
|
+
* <MyScene />
|
|
32
|
+
* </CanvasProvider>
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { Canvas } from '@react-three/fiber';
|
|
36
|
+
import type { ReactNode } from 'react';
|
|
37
|
+
import { useWebGLReady } from '@/components/webgl/WebGLContext';
|
|
38
|
+
|
|
39
|
+
export interface CanvasProviderProps {
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
fallback: ReactNode;
|
|
42
|
+
/** Override the DPR range. Default [1, 2] — Retina-aware. */
|
|
43
|
+
dpr?: [number, number];
|
|
44
|
+
/** Additional className merged onto the <Canvas> host. */
|
|
45
|
+
className?: string;
|
|
46
|
+
/** Mount a DOM wrapper around the Canvas (useful for pinning, absolute positioning). */
|
|
47
|
+
wrapperClassName?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function CanvasProvider({
|
|
51
|
+
children,
|
|
52
|
+
fallback,
|
|
53
|
+
dpr = [1, 2],
|
|
54
|
+
className = '',
|
|
55
|
+
wrapperClassName = '',
|
|
56
|
+
}: CanvasProviderProps) {
|
|
57
|
+
const ready = useWebGLReady();
|
|
58
|
+
|
|
59
|
+
if (!ready) {
|
|
60
|
+
// Fallback path — no Canvas, no R3F tree, no bundle cost beyond the
|
|
61
|
+
// detector itself. The caller supplies a SceneFallback so the shape
|
|
62
|
+
// of the viewport stays stable.
|
|
63
|
+
return <>{fallback}</>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={`relative ${wrapperClassName}`.trim()} data-canvas-host>
|
|
68
|
+
<Canvas
|
|
69
|
+
className={className}
|
|
70
|
+
frameloop="demand"
|
|
71
|
+
dpr={dpr}
|
|
72
|
+
gl={{
|
|
73
|
+
antialias: true,
|
|
74
|
+
alpha: true,
|
|
75
|
+
powerPreference: 'high-performance',
|
|
76
|
+
// stencil/depth defaults are fine for scroll-choreography use
|
|
77
|
+
// cases; advanced effects (post-processing) can request overrides
|
|
78
|
+
// via <Canvas> prop drilling in user code.
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</Canvas>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* InvalidateOnScroll — ties R3F's demand-mode rendering to the Lenis
|
|
5
|
+
* smooth-scroll master tick (ADR-3D-03, ADR-LENIS-GSAP).
|
|
6
|
+
*
|
|
7
|
+
* Background: motion-premium establishes a single RAF loop where Lenis
|
|
8
|
+
* drives gsap.ticker via `gsap.ticker.add((t) => lenis.raf(t * 1000))`.
|
|
9
|
+
* The 2D side (ScrollTrigger, page transitions) piggybacks on this.
|
|
10
|
+
* The 3D side must NOT start a second RAF — doing so fragments the
|
|
11
|
+
* frame budget and drops FPS on 120Hz displays.
|
|
12
|
+
*
|
|
13
|
+
* This component mounts INSIDE <CanvasProvider> so it has access to
|
|
14
|
+
* R3F's invalidate() function. On every GSAP tick, it calls invalidate().
|
|
15
|
+
* R3F then renders exactly ONE frame, but only if any demand-mode
|
|
16
|
+
* signal (scroll delta, component invalidation, tween update) was
|
|
17
|
+
* raised since the last render.
|
|
18
|
+
*
|
|
19
|
+
* The net effect:
|
|
20
|
+
* - Idle: zero renders (no scroll, no animation → no invalidate).
|
|
21
|
+
* - Scroll-in-progress: one render per Lenis tick (≈16ms on 60Hz,
|
|
22
|
+
* ≈8ms on 120Hz), matched to the 2D motion tick exactly.
|
|
23
|
+
* - Non-scroll animation (hover effects, looping meshes): components
|
|
24
|
+
* call useFrame, which R3F also honours under frameloop="demand"
|
|
25
|
+
* as long as they call invalidate() explicitly.
|
|
26
|
+
*
|
|
27
|
+
* Must be rendered INSIDE a <Canvas>. useThree() will throw outside.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { useEffect } from 'react';
|
|
31
|
+
import { useThree } from '@react-three/fiber';
|
|
32
|
+
import { gsap } from 'gsap';
|
|
33
|
+
|
|
34
|
+
export function InvalidateOnScroll() {
|
|
35
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
// gsap.ticker.add returns the handler itself for unregistration.
|
|
39
|
+
const tick = () => {
|
|
40
|
+
invalidate();
|
|
41
|
+
};
|
|
42
|
+
gsap.ticker.add(tick);
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
gsap.ticker.remove(tick);
|
|
46
|
+
};
|
|
47
|
+
}, [invalidate]);
|
|
48
|
+
|
|
49
|
+
// Renders nothing — this is a RAF-coordination concern, not a scene node.
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MeshCrossfade — per-mesh variant of ScrollCrossfade. Use when a single
|
|
5
|
+
* object should morph through a sequence of states (different geometry,
|
|
6
|
+
* material, or texture) as scroll advances, without rebuilding the
|
|
7
|
+
* scene graph.
|
|
8
|
+
*
|
|
9
|
+
* Differs from ScrollCrossfade:
|
|
10
|
+
* - ScrollCrossfade: multiple independent scene groups, pick one.
|
|
11
|
+
* - MeshCrossfade: single mesh slot, swap its contents — no GPU
|
|
12
|
+
* cost for inactive variants because unselected
|
|
13
|
+
* meshes are not instantiated.
|
|
14
|
+
*
|
|
15
|
+
* The active variant is determined by nearest `at` position to the
|
|
16
|
+
* current scroll progress. Hysteresis is a small threshold (default
|
|
17
|
+
* 2 percent of the pinned range) that prevents rapid flip-flop near
|
|
18
|
+
* segment boundaries.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* <MeshCrossfade
|
|
22
|
+
* variants={[
|
|
23
|
+
* { at: 0.0, render: () => <BoxMesh /> },
|
|
24
|
+
* { at: 0.5, render: () => <SphereMesh /> },
|
|
25
|
+
* { at: 1.0, render: () => <TorusMesh /> },
|
|
26
|
+
* ]}
|
|
27
|
+
* />
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { useContext, useMemo, useRef, useState } from 'react';
|
|
31
|
+
import { useFrame } from '@react-three/fiber';
|
|
32
|
+
import { ScrollSceneContext } from './ScrollScene';
|
|
33
|
+
|
|
34
|
+
export interface MeshVariant {
|
|
35
|
+
/** Progress position (0..1) where this variant is dominant. */
|
|
36
|
+
at: number;
|
|
37
|
+
/** Render function for the variant. Returning null skips rendering. */
|
|
38
|
+
render: () => JSX.Element | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MeshCrossfadeProps {
|
|
42
|
+
variants: MeshVariant[];
|
|
43
|
+
/** Hysteresis band width around segment boundaries (0..1). Default 0.02. */
|
|
44
|
+
hysteresis?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function MeshCrossfade({ variants, hysteresis = 0.02 }: MeshCrossfadeProps) {
|
|
48
|
+
const progressRef = useContext(ScrollSceneContext);
|
|
49
|
+
const sorted = useMemo(() => [...variants].sort((a, b) => a.at - b.at), [variants]);
|
|
50
|
+
const [activeIdx, setActiveIdx] = useState(0);
|
|
51
|
+
const activeIdxRef = useRef(0);
|
|
52
|
+
activeIdxRef.current = activeIdx;
|
|
53
|
+
|
|
54
|
+
useFrame(() => {
|
|
55
|
+
if (!progressRef || sorted.length === 0) return;
|
|
56
|
+
const t = Math.max(0, Math.min(1, progressRef.current.progress));
|
|
57
|
+
|
|
58
|
+
// Find the variant whose `at` is closest to t.
|
|
59
|
+
let best = 0;
|
|
60
|
+
let bestDist = Math.abs(sorted[0].at - t);
|
|
61
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
62
|
+
const d = Math.abs(sorted[i].at - t);
|
|
63
|
+
if (d < bestDist) { best = i; bestDist = d; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Hysteresis: only switch if the new best beats the current by more
|
|
67
|
+
// than `hysteresis`. Without this, positions near a boundary flip
|
|
68
|
+
// between variants on every frame.
|
|
69
|
+
if (best !== activeIdxRef.current) {
|
|
70
|
+
const currentDist = Math.abs(sorted[activeIdxRef.current].at - t);
|
|
71
|
+
if (currentDist - bestDist > hysteresis) {
|
|
72
|
+
setActiveIdx(best);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const active = sorted[activeIdx];
|
|
78
|
+
return active ? active.render() : null;
|
|
79
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ScrollCrossfade — fades between an ordered sequence of scene "stops"
|
|
5
|
+
* as scroll progress advances. The 3D analogue of motion-premium's 2D
|
|
6
|
+
* BackgroundCrossfadeProvider.
|
|
7
|
+
*
|
|
8
|
+
* Mounts inside <CanvasProvider> + <ScrollSceneContext>. Each stop is
|
|
9
|
+
* a React node (typically a <group> containing meshes). Stops whose
|
|
10
|
+
* index corresponds to the current progress segment animate their
|
|
11
|
+
* material opacity in; neighbour stops animate out; distant stops stay
|
|
12
|
+
* fully transparent.
|
|
13
|
+
*
|
|
14
|
+
* Internal material handling: the component sets `visible` false on
|
|
15
|
+
* groups whose opacity is effectively zero so the GPU skips them
|
|
16
|
+
* entirely.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* <ScrollCrossfade stops={[
|
|
20
|
+
* <HeroScene key="hero" />,
|
|
21
|
+
* <ZoomScene key="zoom" />,
|
|
22
|
+
* <OutroScene key="outro" />,
|
|
23
|
+
* ]} />
|
|
24
|
+
*
|
|
25
|
+
* The number of stops determines the segment boundaries evenly across
|
|
26
|
+
* the pinned progress range. For uneven spacing, supply the optional
|
|
27
|
+
* `at` array (same length) with 0..1 positions.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Children, useContext, useMemo, useRef, type ReactNode } from 'react';
|
|
31
|
+
import { useFrame } from '@react-three/fiber';
|
|
32
|
+
import { Group } from 'three';
|
|
33
|
+
import { ScrollSceneContext } from './ScrollScene';
|
|
34
|
+
|
|
35
|
+
export interface ScrollCrossfadeProps {
|
|
36
|
+
stops: ReactNode[];
|
|
37
|
+
/** Optional progress positions per stop; defaults to evenly spaced. */
|
|
38
|
+
at?: number[];
|
|
39
|
+
/** How wide the crossfade band is (0..1). Default 0.15 = 15 percent of the pinned range. */
|
|
40
|
+
fade?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MIN_VISIBLE = 0.01;
|
|
44
|
+
|
|
45
|
+
export function ScrollCrossfade({ stops, at, fade = 0.15 }: ScrollCrossfadeProps) {
|
|
46
|
+
const progressRef = useContext(ScrollSceneContext);
|
|
47
|
+
const groupRefs = useRef<(Group | null)[]>([]);
|
|
48
|
+
|
|
49
|
+
const positions = useMemo(() => {
|
|
50
|
+
if (at && at.length === stops.length) return at;
|
|
51
|
+
if (stops.length === 1) return [0.5];
|
|
52
|
+
return stops.map((_, i) => i / (stops.length - 1));
|
|
53
|
+
}, [at, stops]);
|
|
54
|
+
|
|
55
|
+
useFrame(() => {
|
|
56
|
+
if (!progressRef) return;
|
|
57
|
+
const t = Math.max(0, Math.min(1, progressRef.current.progress));
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < stops.length; i++) {
|
|
60
|
+
const group = groupRefs.current[i];
|
|
61
|
+
if (!group) continue;
|
|
62
|
+
const distance = Math.abs(t - positions[i]);
|
|
63
|
+
const opacity = Math.max(0, 1 - distance / fade);
|
|
64
|
+
group.visible = opacity > MIN_VISIBLE;
|
|
65
|
+
|
|
66
|
+
// Cascade opacity into any material that has it. This is a
|
|
67
|
+
// best-effort pass — authors who need per-material control can
|
|
68
|
+
// skip this component and drive opacity from their own useFrame.
|
|
69
|
+
group.traverse((obj) => {
|
|
70
|
+
const mat = (obj as unknown as { material?: { opacity?: number; transparent?: boolean } }).material;
|
|
71
|
+
if (mat && typeof mat.opacity === 'number') {
|
|
72
|
+
mat.opacity = opacity;
|
|
73
|
+
mat.transparent = true;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
{Children.map(stops, (child, i) => (
|
|
82
|
+
<group ref={(el) => { groupRefs.current[i] = el; }}>
|
|
83
|
+
{child}
|
|
84
|
+
</group>
|
|
85
|
+
))}
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ScrollScene — a pinned R3F scene whose animation progress is driven
|
|
5
|
+
* by GSAP ScrollTrigger (the same engine that drives the 2D side of
|
|
6
|
+
* motion-premium). This keeps the 3D scene on the single-RAF contract
|
|
7
|
+
* (ADR-3D-03) and lets scenes share the same scroll source of truth as
|
|
8
|
+
* pinned 2D sections.
|
|
9
|
+
*
|
|
10
|
+
* Pattern:
|
|
11
|
+
* 1. User wraps a section of the page in <ScrollScene>. The component
|
|
12
|
+
* creates a ScrollTrigger pinning that section for `length` of
|
|
13
|
+
* viewport height.
|
|
14
|
+
* 2. ScrollTrigger's `onUpdate` writes the 0..1 progress into a ref
|
|
15
|
+
* exposed through ScrollSceneContext.
|
|
16
|
+
* 3. Descendants (CameraRig, ScrollCrossfade, MeshCrossfade) read the
|
|
17
|
+
* ref via useFrame on every R3F render (which InvalidateOnScroll
|
|
18
|
+
* triggers once per GSAP tick).
|
|
19
|
+
*
|
|
20
|
+
* This design keeps R3F decoupled from any extra state library —
|
|
21
|
+
* progress lives in a plain ref. React re-renders are NOT used to
|
|
22
|
+
* propagate progress (that would tank framerate); only three-js scene
|
|
23
|
+
* mutations happen inside useFrame.
|
|
24
|
+
*
|
|
25
|
+
* Consumers must mount this OUTSIDE <Canvas> (it lays out a scrolling
|
|
26
|
+
* section). The scene children it yields via `renderScene` mount INSIDE
|
|
27
|
+
* the caller's CanvasProvider.
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* const sceneRef = useRef<ScrollSceneRef>(null);
|
|
31
|
+
* return (
|
|
32
|
+
* <>
|
|
33
|
+
* <ScrollScene ref={sceneRef} length={3}>
|
|
34
|
+
* <Container>...overlay HTML...</Container>
|
|
35
|
+
* </ScrollScene>
|
|
36
|
+
* <CanvasProvider fallback={<SceneFallback fullBleed />}>
|
|
37
|
+
* <InvalidateOnScroll />
|
|
38
|
+
* <ScrollSceneContext.Provider value={sceneRef.current?.progress}>
|
|
39
|
+
* <CameraRig />
|
|
40
|
+
* <MyMesh />
|
|
41
|
+
* </ScrollSceneContext.Provider>
|
|
42
|
+
* </CanvasProvider>
|
|
43
|
+
* </>
|
|
44
|
+
* );
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { createContext, forwardRef, useEffect, useImperativeHandle, useRef, type ReactNode, type MutableRefObject } from 'react';
|
|
48
|
+
import { gsap } from 'gsap';
|
|
49
|
+
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
|
50
|
+
|
|
51
|
+
export type ScrollPhase = 'before' | 'entering' | 'pinned' | 'exiting' | 'after';
|
|
52
|
+
|
|
53
|
+
export interface ScrollProgressRef {
|
|
54
|
+
/** 0..1 across the pinned range. */
|
|
55
|
+
progress: number;
|
|
56
|
+
/** Coarse lifecycle phase for gating sub-animations. */
|
|
57
|
+
phase: ScrollPhase;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ScrollSceneRef {
|
|
61
|
+
/** Ref object R3F components read during useFrame. */
|
|
62
|
+
progress: MutableRefObject<ScrollProgressRef>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ScrollSceneProps {
|
|
66
|
+
/** Pin length as a multiple of viewport height (e.g. 3 = pin for 3 viewports of scroll). */
|
|
67
|
+
length?: number;
|
|
68
|
+
/** Extra scrub smoothing (0 = linear, >0 = eased). */
|
|
69
|
+
scrub?: number | boolean;
|
|
70
|
+
/** Additional className for the outer section. */
|
|
71
|
+
className?: string;
|
|
72
|
+
/** Overlay HTML (anything non-3D that should scroll-pin with the canvas). */
|
|
73
|
+
children?: ReactNode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Context read by scene-children under CanvasProvider. Null until ScrollScene mounts. */
|
|
77
|
+
export const ScrollSceneContext = createContext<MutableRefObject<ScrollProgressRef> | null>(null);
|
|
78
|
+
|
|
79
|
+
export const ScrollScene = forwardRef<ScrollSceneRef, ScrollSceneProps>(function ScrollScene(
|
|
80
|
+
{ length = 2, scrub = true, className = '', children },
|
|
81
|
+
ref,
|
|
82
|
+
) {
|
|
83
|
+
const sectionRef = useRef<HTMLElement | null>(null);
|
|
84
|
+
const progressRef = useRef<ScrollProgressRef>({ progress: 0, phase: 'before' });
|
|
85
|
+
|
|
86
|
+
useImperativeHandle(ref, () => ({ progress: progressRef }), []);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!sectionRef.current) return;
|
|
90
|
+
gsap.registerPlugin(ScrollTrigger);
|
|
91
|
+
|
|
92
|
+
const trigger = ScrollTrigger.create({
|
|
93
|
+
trigger: sectionRef.current,
|
|
94
|
+
start: 'top top',
|
|
95
|
+
end: `+=${length * 100}%`,
|
|
96
|
+
pin: true,
|
|
97
|
+
scrub,
|
|
98
|
+
onEnter: () => { progressRef.current.phase = 'entering'; },
|
|
99
|
+
onLeave: () => { progressRef.current.phase = 'after'; },
|
|
100
|
+
onEnterBack: () => { progressRef.current.phase = 'entering'; },
|
|
101
|
+
onLeaveBack: () => { progressRef.current.phase = 'before'; },
|
|
102
|
+
onUpdate: (self) => {
|
|
103
|
+
progressRef.current.progress = self.progress;
|
|
104
|
+
if (self.progress > 0 && self.progress < 1) progressRef.current.phase = 'pinned';
|
|
105
|
+
else if (self.progress >= 1) progressRef.current.phase = 'exiting';
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return () => { trigger.kill(); };
|
|
110
|
+
}, [length, scrub]);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<section
|
|
114
|
+
ref={sectionRef}
|
|
115
|
+
className={`relative h-svh w-full ${className}`.trim()}
|
|
116
|
+
data-scroll-scene
|
|
117
|
+
>
|
|
118
|
+
{children}
|
|
119
|
+
</section>
|
|
120
|
+
);
|
|
121
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SceneFallback — rendered when WebGL is unavailable or the user asked
|
|
3
|
+
* for reduced motion.
|
|
4
|
+
*
|
|
5
|
+
* The design intent (ADR-3D-05, ADR-3D-08): users who cannot see a
|
|
6
|
+
* canvas should see a considered static design, not a broken-looking
|
|
7
|
+
* blank area. The fallback reads like an artistic still — a single
|
|
8
|
+
* gradient background with optional children (headline, logo, CTA)
|
|
9
|
+
* that still honour the brand palette.
|
|
10
|
+
*
|
|
11
|
+
* Props:
|
|
12
|
+
* children — optional foreground content (headline, CTA)
|
|
13
|
+
* className — extra Tailwind classes to merge
|
|
14
|
+
* fullBleed — when true, fills viewport (use for heroes)
|
|
15
|
+
* accessibleLabel — passed to role="img" aria-label. When the scene
|
|
16
|
+
* is decorative, set to '' and add aria-hidden instead.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* <SceneFallback fullBleed accessibleLabel="Product showcase backdrop">
|
|
20
|
+
* <h1 className="text-display-lg">Welcome</h1>
|
|
21
|
+
* </SceneFallback>
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ReactNode } from 'react';
|
|
25
|
+
|
|
26
|
+
export interface SceneFallbackProps {
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
fullBleed?: boolean;
|
|
30
|
+
accessibleLabel?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function SceneFallback({
|
|
34
|
+
children,
|
|
35
|
+
className = '',
|
|
36
|
+
fullBleed = false,
|
|
37
|
+
accessibleLabel = '',
|
|
38
|
+
}: SceneFallbackProps) {
|
|
39
|
+
const baseClass = fullBleed
|
|
40
|
+
? 'relative w-screen h-svh overflow-hidden'
|
|
41
|
+
: 'relative w-full h-full min-h-[60vh] overflow-hidden';
|
|
42
|
+
|
|
43
|
+
// Brand palette placeholder — resolved at scaffold apply from brand.ts.
|
|
44
|
+
// The gradient runs from deep to primary for a cinematic-stills feel
|
|
45
|
+
// that echoes the WebGL scene it replaces.
|
|
46
|
+
const gradient = 'bg-[radial-gradient(ellipse_at_center,{{PRIMARY_HEX}}_0%,{{BG_DEEP_HEX}}_70%)]';
|
|
47
|
+
|
|
48
|
+
const a11y = accessibleLabel
|
|
49
|
+
? { role: 'img', 'aria-label': accessibleLabel }
|
|
50
|
+
: { 'aria-hidden': true };
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
className={`${baseClass} ${gradient} ${className}`.trim()}
|
|
55
|
+
{...a11y}
|
|
56
|
+
data-scene-fallback
|
|
57
|
+
>
|
|
58
|
+
{children && (
|
|
59
|
+
<div className="relative z-10 h-full w-full flex items-center justify-center">
|
|
60
|
+
{children}
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WebGLContext — React context that exposes runtime WebGL availability.
|
|
5
|
+
*
|
|
6
|
+
* Pattern (ADR-3D-05): the detect.ts probe runs ONCE on mount. The
|
|
7
|
+
* result lives in context so every 3D template checks the same source
|
|
8
|
+
* of truth without re-probing. Downstream <ScrollScene> and
|
|
9
|
+
* <CanvasProvider> short-circuit to <SceneFallback> when availability
|
|
10
|
+
* is false — no <Canvas> ever mounts, no Three.js bundle pays its
|
|
11
|
+
* cost on devices that cannot render it.
|
|
12
|
+
*
|
|
13
|
+
* State shape:
|
|
14
|
+
* status: 'pending' | 'available' | 'unavailable'
|
|
15
|
+
* reason: 'no-webgl' | 'reduced-motion' | 'no-window' | null
|
|
16
|
+
*
|
|
17
|
+
* 'pending' allows the first render to paint a static fallback; the
|
|
18
|
+
* effect then updates the context. This avoids the "canvas flashes for
|
|
19
|
+
* one frame before falling back" hydration bug.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
|
23
|
+
import { detectWebGL, type WebGLAvailability } from '@/lib/webgl/detect';
|
|
24
|
+
|
|
25
|
+
export interface WebGLContextValue {
|
|
26
|
+
status: 'pending' | 'available' | 'unavailable';
|
|
27
|
+
reason: WebGLAvailability extends { reason: infer R } ? R | null : null;
|
|
28
|
+
webglVersion: 1 | 2 | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_VALUE: WebGLContextValue = {
|
|
32
|
+
status: 'pending',
|
|
33
|
+
reason: null,
|
|
34
|
+
webglVersion: null,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const WebGLContext = createContext<WebGLContextValue>(DEFAULT_VALUE);
|
|
38
|
+
|
|
39
|
+
export function WebGLProvider({ children }: { children: ReactNode }) {
|
|
40
|
+
const [value, setValue] = useState<WebGLContextValue>(DEFAULT_VALUE);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const result = detectWebGL();
|
|
44
|
+
if (result.available) {
|
|
45
|
+
setValue({ status: 'available', reason: null, webglVersion: result.version });
|
|
46
|
+
} else {
|
|
47
|
+
setValue({ status: 'unavailable', reason: result.reason, webglVersion: null });
|
|
48
|
+
}
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
return <WebGLContext.Provider value={value}>{children}</WebGLContext.Provider>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Hook — returns the full context value. Prefer `useWebGLReady()` when
|
|
56
|
+
* you only need the boolean.
|
|
57
|
+
*/
|
|
58
|
+
export function useWebGLContext(): WebGLContextValue {
|
|
59
|
+
return useContext(WebGLContext);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hook — boolean "may we mount a Canvas now?". Returns false on pending
|
|
64
|
+
* so the first render is always safe (fallback renders, then upgrades).
|
|
65
|
+
*/
|
|
66
|
+
export function useWebGLReady(): boolean {
|
|
67
|
+
return useContext(WebGLContext).status === 'available';
|
|
68
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebGL availability detection — client-only.
|
|
3
|
+
*
|
|
4
|
+
* Runs a one-shot probe: try to acquire a WebGL2 context, fall back to
|
|
5
|
+
* WebGL1, return null when neither is available (Safari ITP with
|
|
6
|
+
* strict mode, corporate browser lockdowns, very old hardware).
|
|
7
|
+
*
|
|
8
|
+
* ADR-3D-05: treat reduced-motion as WebGL-off from a UX perspective.
|
|
9
|
+
* A user who has asked for reduced motion does not want a scroll-driven
|
|
10
|
+
* 3D scene — even if WebGL is technically available. Returning false in
|
|
11
|
+
* this case lets downstream components render the SceneFallback without
|
|
12
|
+
* a second check.
|
|
13
|
+
*
|
|
14
|
+
* ADR-3D-08 coordinates with the hook at the component layer: the
|
|
15
|
+
* canvas provider calls detect() once and stores the result in a React
|
|
16
|
+
* context (see WebGLContext.tsx).
|
|
17
|
+
*
|
|
18
|
+
* Pure on the browser: does NOT render, mount, or dispatch events.
|
|
19
|
+
* Safe to call inside useEffect.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type WebGLAvailability =
|
|
23
|
+
| { available: true; version: 2 | 1 }
|
|
24
|
+
| { available: false; reason: 'no-webgl' | 'reduced-motion' | 'no-window' };
|
|
25
|
+
|
|
26
|
+
export function detectWebGL(): WebGLAvailability {
|
|
27
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
28
|
+
// SSR / Node: pretend WebGL is unavailable so client-only mounts handle it.
|
|
29
|
+
return { available: false, reason: 'no-window' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Reduced-motion opt-out comes first — cheap, and a pref miss saves us
|
|
33
|
+
// from ever probing a context we will not use.
|
|
34
|
+
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
|
35
|
+
return { available: false, reason: 'reduced-motion' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let canvas: HTMLCanvasElement | null = null;
|
|
39
|
+
try {
|
|
40
|
+
canvas = document.createElement('canvas');
|
|
41
|
+
const gl2 = canvas.getContext('webgl2');
|
|
42
|
+
if (gl2) {
|
|
43
|
+
// Best-effort cleanup so we do not leak the probe context.
|
|
44
|
+
(gl2 as WebGL2RenderingContext).getExtension('WEBGL_lose_context')?.loseContext();
|
|
45
|
+
return { available: true, version: 2 };
|
|
46
|
+
}
|
|
47
|
+
const gl1 = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
|
48
|
+
if (gl1) {
|
|
49
|
+
(gl1 as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext();
|
|
50
|
+
return { available: true, version: 1 };
|
|
51
|
+
}
|
|
52
|
+
return { available: false, reason: 'no-webgl' };
|
|
53
|
+
} catch {
|
|
54
|
+
return { available: false, reason: 'no-webgl' };
|
|
55
|
+
} finally {
|
|
56
|
+
if (canvas) {
|
|
57
|
+
// Drop the probe node.
|
|
58
|
+
canvas.width = 0;
|
|
59
|
+
canvas.height = 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Shorthand boolean — use when caller only cares if Canvas should mount.
|
|
66
|
+
*/
|
|
67
|
+
export function isWebGLAvailable(): boolean {
|
|
68
|
+
return detectWebGL().available;
|
|
69
|
+
}
|