@abuzareal/contour-ui 0.1.2 → 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 (72) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CODE_OF_CONDUCT.md +5 -0
  3. package/CONTRACTS.md +93 -0
  4. package/CONTRIBUTING.md +17 -0
  5. package/NEXTJS.md +51 -0
  6. package/PUBLIC-API.md +231 -0
  7. package/README.md +62 -6
  8. package/RELEASING.md +14 -40
  9. package/SECURITY.md +1 -1
  10. package/dist/components/hero/SceneBoundary.js +1 -0
  11. package/dist/components/hero/Sculpture.js +6 -4
  12. package/dist/components/three/ObjectStudy.js +7 -3
  13. package/dist/components/ui/Alert.js +1 -0
  14. package/dist/components/ui/BrandMark.js +1 -0
  15. package/dist/components/ui/Checkbox.js +8 -1
  16. package/dist/components/ui/CountUp.js +1 -0
  17. package/dist/components/ui/DataTable.js +1 -0
  18. package/dist/components/ui/Dialog.js +1 -0
  19. package/dist/components/ui/DropdownMenu.d.ts +7 -1
  20. package/dist/components/ui/DropdownMenu.js +62 -14
  21. package/dist/components/ui/FlipCard.js +1 -0
  22. package/dist/components/ui/Magnetic.js +1 -0
  23. package/dist/components/ui/Pagination.js +1 -0
  24. package/dist/components/ui/Popover.d.ts +4 -1
  25. package/dist/components/ui/Popover.js +7 -5
  26. package/dist/components/ui/RadioGroup.js +1 -0
  27. package/dist/components/ui/Reveal.js +1 -0
  28. package/dist/components/ui/ScrollProgress.js +1 -0
  29. package/dist/components/ui/SegmentedControl.js +1 -0
  30. package/dist/components/ui/SelectField.d.ts +11 -1
  31. package/dist/components/ui/SelectField.js +6 -4
  32. package/dist/components/ui/Slider.js +1 -0
  33. package/dist/components/ui/Switch.js +1 -0
  34. package/dist/components/ui/Tabs.d.ts +4 -1
  35. package/dist/components/ui/Tabs.js +12 -5
  36. package/dist/components/ui/Tag.js +1 -0
  37. package/dist/components/ui/TextArea.d.ts +8 -1
  38. package/dist/components/ui/TextArea.js +21 -7
  39. package/dist/components/ui/TextField.d.ts +10 -1
  40. package/dist/components/ui/TextField.js +17 -6
  41. package/dist/components/ui/ThemeToggle.js +1 -0
  42. package/dist/components/ui/TiltCard.js +1 -0
  43. package/dist/components/ui/ToastProvider.js +1 -0
  44. package/dist/components/ui/Tooltip.js +1 -0
  45. package/dist/fonts/dm-mono-LICENSE.txt +93 -0
  46. package/dist/fonts/dm-mono-latin-400-normal.woff2 +0 -0
  47. package/dist/fonts/dm-sans-LICENSE.txt +93 -0
  48. package/dist/fonts/dm-sans-latin-wght-normal.woff2 +0 -0
  49. package/dist/fonts/space-grotesk-LICENSE.txt +93 -0
  50. package/dist/fonts/space-grotesk-latin-wght-normal.woff2 +0 -0
  51. package/dist/fonts-latin.css +3 -3
  52. package/dist/hooks/useClipboardCopy.js +30 -6
  53. package/dist/hooks/useControlRef.d.ts +6 -0
  54. package/dist/hooks/useControlRef.js +13 -0
  55. package/dist/hooks/useDismiss.js +1 -0
  56. package/dist/hooks/useFormReset.d.ts +3 -0
  57. package/dist/hooks/useFormReset.js +31 -0
  58. package/dist/hooks/useHeaderScroll.js +1 -0
  59. package/dist/hooks/useIdleTrigger.js +1 -0
  60. package/dist/hooks/useInitialHashScroll.js +1 -0
  61. package/dist/hooks/useMobileMenu.js +1 -0
  62. package/dist/hooks/useMotionPreference.js +1 -0
  63. package/dist/hooks/useOpenState.d.ts +2 -0
  64. package/dist/hooks/useOpenState.js +15 -0
  65. package/dist/hooks/usePrintPreparation.js +1 -0
  66. package/dist/hooks/useScrollSpy.js +1 -0
  67. package/dist/hooks/useSmoothScroll.js +1 -0
  68. package/dist/hooks/useTheme.js +1 -0
  69. package/dist/hooks/useThreeStage.d.ts +4 -0
  70. package/dist/hooks/useThreeStage.js +165 -128
  71. package/dist/hooks/useToast.js +1 -0
  72. package/package.json +19 -8
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  /** Open state for the mobile menu, closing on Escape, outside pointer, or desktop resize. */
2
3
  import { useEffect, useState } from "react";
3
4
  const desktopQuery = "(min-width: 768px)";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  /** Tracks the user's `prefers-reduced-motion` setting and updates when it changes. */
2
3
  import { useEffect, useState } from "react";
3
4
  const query = "(prefers-reduced-motion: reduce)";
@@ -0,0 +1,2 @@
1
+ /** Controlled and uncontrolled disclosure state share the same change contract. */
2
+ export default function useOpenState(open: boolean | undefined, defaultOpen: boolean, onOpenChange?: (open: boolean) => void): readonly [boolean, (next: boolean) => void];
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { useCallback, useState } from "react";
3
+ /** Controlled and uncontrolled disclosure state share the same change contract. */
4
+ export default function useOpenState(open, defaultOpen, onOpenChange) {
5
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
6
+ const current = open ?? internalOpen;
7
+ const change = useCallback((next) => {
8
+ if (next === current)
9
+ return;
10
+ if (open === undefined)
11
+ setInternalOpen(next);
12
+ onOpenChange?.(next);
13
+ }, [current, open, onOpenChange]);
14
+ return [current, change];
15
+ }
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  /** Prints the full page: opens collapsed disclosures and uses the light theme, then restores both. */
2
3
  import { useEffect } from "react";
3
4
  export default function usePrintPreparation() {
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  /** Returns the id of the last listed element whose top has scrolled past the activation line. */
2
3
  import { useEffect, useState } from "react";
3
4
  /** Fraction of the viewport height at which an element becomes "current". */
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  /** Enables Lenis smooth scrolling (and animated anchor jumps) while motion is allowed. */
2
3
  import { useEffect } from "react";
3
4
  import Lenis from "lenis";
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  /** Light/dark theme state: persisted choice, system fallback, and the animated switch. */
2
3
  import { useCallback, useEffect, useSyncExternalStore } from "react";
3
4
  import { flushSync } from "react-dom";
@@ -12,11 +12,15 @@ export type StageScene = {
12
12
  /** Snaps to a still pose when motion is turned off. */
13
13
  rest: () => void;
14
14
  dispose: () => void;
15
+ /** Reflows the still or animated scene after the camera aspect changes. */
16
+ resize?: () => void;
15
17
  };
16
18
  export type StageContext = {
17
19
  scene: THREE.Scene;
18
20
  camera: THREE.PerspectiveCamera;
19
21
  renderer: THREE.WebGLRenderer;
22
+ /** Register resources immediately, including those allocated before setup returns. */
23
+ onDispose?: (cleanup: () => void) => void;
20
24
  };
21
25
  type StageOptions = {
22
26
  motionEnabled: boolean;
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  /** Shared Three.js stage: renderer, studio lighting, resizing, a visibility-aware render loop, pointer tracking, and cleanup. */
2
3
  import { useEffect, useRef } from "react";
3
4
  import * as THREE from "three";
@@ -16,140 +17,176 @@ export default function useThreeStage(hostRef, { motionEnabled, setup, cameraDis
16
17
  const host = hostRef.current;
17
18
  if (!host)
18
19
  return;
19
- let renderer;
20
- try {
21
- renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
22
- }
23
- catch {
24
- host.dataset.state = "unavailable";
25
- return;
26
- }
27
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, maxPixelRatio));
28
- renderer.setClearColor(0x000000, 0);
29
- // Neutral tone mapping keeps the lime close to the CSS accent colour.
30
- renderer.toneMapping = THREE.NeutralToneMapping;
31
- renderer.toneMappingExposure = 1.05;
32
- renderer.domElement.setAttribute("aria-hidden", "true");
33
- host.appendChild(renderer.domElement);
34
- // Studio lighting shared by every stage so materials read consistently.
35
- const scene = new THREE.Scene();
36
- const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 30);
37
- camera.position.set(0, 0, 10.5);
38
- const environment = new RoomEnvironment();
39
- const generator = new THREE.PMREMGenerator(renderer);
40
- const environmentMap = generator.fromScene(environment, 0.04);
41
- scene.environment = environmentMap.texture;
42
- environment.dispose();
43
- generator.dispose();
44
- const keyLight = new THREE.DirectionalLight(0xffffff, 4);
45
- keyLight.position.set(4, 5, 6);
46
- const rimLight = new THREE.DirectionalLight(0xcad9ff, 3);
47
- rimLight.position.set(-5, 1, -2);
48
- scene.add(keyLight, rimLight, new THREE.AmbientLight(0xffffff, 0.7));
49
- const stage = setupRef.current.setup({ scene, camera, renderer });
50
- // Loop state: frames run only while visible, focused, and motion is on.
51
- const pointer = new THREE.Vector2();
52
- let visible = true;
53
- let contextLost = false;
54
- let running = false;
55
- let frame = 0;
56
- let elapsed = 0;
57
- let previousTime = 0;
58
- const render = () => {
59
- if (contextLost)
60
- return;
61
- renderer.render(scene, camera);
62
- host.dataset.state = "ready";
63
- };
64
- const resize = () => {
65
- const { width, height } = host.getBoundingClientRect();
66
- if (!width || !height)
67
- return;
68
- camera.aspect = width / height;
69
- camera.position.z = setupRef.current.cameraDistance(camera.aspect);
70
- camera.updateProjectionMatrix();
71
- renderer.setSize(width, height);
72
- render();
73
- };
74
- const trackPointer = (event) => {
75
- if (event.pointerType === "touch" || !motionRef.current)
76
- return;
77
- const bounds = host.getBoundingClientRect();
78
- pointer.set((event.clientX - bounds.left) / bounds.width - 0.5, (event.clientY - bounds.top) / bounds.height - 0.5);
20
+ const disposers = [];
21
+ const own = (dispose) => {
22
+ let disposed = false;
23
+ const release = () => {
24
+ if (disposed)
25
+ return;
26
+ disposed = true;
27
+ dispose();
28
+ };
29
+ disposers.push(release);
30
+ return release;
79
31
  };
80
- const resetPointer = () => pointer.set(0, 0);
81
- const animate = (time) => {
82
- if (!running)
83
- return;
84
- const delta = Math.min(Math.max(time - previousTime, 0) / 1000, 0.05);
85
- previousTime = time;
86
- elapsed += delta;
87
- stage.update({ delta, elapsed, pointer });
88
- render();
89
- frame = requestAnimationFrame(animate);
90
- };
91
- /** Starts or stops the loop to match visibility and motion; snaps to rest when paused. */
92
- const sync = () => {
93
- const motion = motionRef.current;
94
- const shouldRun = motion && visible && !document.hidden && !contextLost;
95
- if (shouldRun && !running) {
96
- running = true;
97
- previousTime = performance.now();
98
- frame = requestAnimationFrame(animate);
32
+ const cleanup = () => {
33
+ syncRef.current = undefined;
34
+ for (const dispose of disposers.reverse()) {
35
+ try {
36
+ dispose();
37
+ }
38
+ catch (error) {
39
+ console.error("Contour stage cleanup failed", error);
40
+ }
99
41
  }
100
- else if (!shouldRun && running) {
42
+ disposers.length = 0;
43
+ };
44
+ try {
45
+ const renderer = new THREE.WebGLRenderer({
46
+ alpha: true,
47
+ antialias: true,
48
+ });
49
+ own(() => renderer.dispose());
50
+ own(() => renderer.domElement.remove());
51
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, maxPixelRatio));
52
+ renderer.setClearColor(0x000000, 0);
53
+ // Neutral tone mapping keeps the lime close to the CSS accent colour.
54
+ renderer.toneMapping = THREE.NeutralToneMapping;
55
+ renderer.toneMappingExposure = 1.05;
56
+ renderer.domElement.setAttribute("aria-hidden", "true");
57
+ host.appendChild(renderer.domElement);
58
+ // Studio lighting shared by every stage so materials read consistently.
59
+ const scene = new THREE.Scene();
60
+ const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 30);
61
+ camera.position.set(0, 0, 10.5);
62
+ const environment = new RoomEnvironment();
63
+ const releaseEnvironment = own(() => environment.dispose());
64
+ const generator = new THREE.PMREMGenerator(renderer);
65
+ const releaseGenerator = own(() => generator.dispose());
66
+ const environmentMap = generator.fromScene(environment, 0.04);
67
+ own(() => environmentMap.dispose());
68
+ scene.environment = environmentMap.texture;
69
+ releaseEnvironment();
70
+ releaseGenerator();
71
+ const keyLight = new THREE.DirectionalLight(0xffffff, 4);
72
+ keyLight.position.set(4, 5, 6);
73
+ const rimLight = new THREE.DirectionalLight(0xcad9ff, 3);
74
+ rimLight.position.set(-5, 1, -2);
75
+ scene.add(keyLight, rimLight, new THREE.AmbientLight(0xffffff, 0.7));
76
+ const stage = setupRef.current.setup({
77
+ scene,
78
+ camera,
79
+ renderer,
80
+ onDispose: own,
81
+ });
82
+ own(() => stage.dispose());
83
+ // Loop state: frames run only while visible, focused, and motion is on.
84
+ const pointer = new THREE.Vector2();
85
+ let visible = true;
86
+ let contextLost = false;
87
+ let running = false;
88
+ let frame = 0;
89
+ let elapsed = 0;
90
+ let previousTime = 0;
91
+ own(() => {
101
92
  running = false;
102
93
  cancelAnimationFrame(frame);
103
- }
104
- if (!motion) {
105
- pointer.set(0, 0);
106
- stage.rest();
94
+ });
95
+ const render = () => {
96
+ if (contextLost)
97
+ return;
98
+ renderer.render(scene, camera);
99
+ host.dataset.state = "ready";
100
+ };
101
+ const resize = () => {
102
+ const { width, height } = host.getBoundingClientRect();
103
+ if (!width || !height)
104
+ return;
105
+ camera.aspect = width / height;
106
+ camera.position.z = setupRef.current.cameraDistance(camera.aspect);
107
+ camera.updateProjectionMatrix();
108
+ renderer.setSize(width, height);
109
+ stage.resize?.();
107
110
  render();
108
- }
109
- };
110
- syncRef.current = sync;
111
- const loseContext = (event) => {
112
- event.preventDefault();
113
- contextLost = true;
114
- host.dataset.state = "unavailable";
115
- sync();
116
- };
117
- const restoreContext = () => {
118
- contextLost = false;
119
- render();
120
- sync();
121
- };
122
- const visibilityObserver = new IntersectionObserver(([entry]) => {
123
- visible = entry.isIntersecting;
124
- if (visible)
111
+ };
112
+ const trackPointer = (event) => {
113
+ if (event.pointerType === "touch" || !motionRef.current)
114
+ return;
115
+ const bounds = host.getBoundingClientRect();
116
+ pointer.set((event.clientX - bounds.left) / bounds.width - 0.5, (event.clientY - bounds.top) / bounds.height - 0.5);
117
+ };
118
+ const resetPointer = () => pointer.set(0, 0);
119
+ const animate = (time) => {
120
+ if (!running)
121
+ return;
122
+ const delta = Math.min(Math.max(time - previousTime, 0) / 1000, 0.05);
123
+ previousTime = time;
124
+ elapsed += delta;
125
+ stage.update({ delta, elapsed, pointer });
126
+ render();
127
+ frame = requestAnimationFrame(animate);
128
+ };
129
+ /** Starts or stops the loop to match visibility and motion; snaps to rest when paused. */
130
+ const sync = () => {
131
+ const motion = motionRef.current;
132
+ const shouldRun = motion && visible && !document.hidden && !contextLost;
133
+ if (shouldRun && !running) {
134
+ running = true;
135
+ previousTime = performance.now();
136
+ frame = requestAnimationFrame(animate);
137
+ }
138
+ else if (!shouldRun && running) {
139
+ running = false;
140
+ cancelAnimationFrame(frame);
141
+ }
142
+ if (!motion) {
143
+ pointer.set(0, 0);
144
+ stage.rest();
145
+ render();
146
+ }
147
+ };
148
+ syncRef.current = sync;
149
+ const loseContext = (event) => {
150
+ event.preventDefault();
151
+ contextLost = true;
152
+ host.dataset.state = "unavailable";
153
+ sync();
154
+ };
155
+ const restoreContext = () => {
156
+ contextLost = false;
125
157
  render();
158
+ sync();
159
+ };
160
+ const visibilityObserver = new IntersectionObserver(([entry]) => {
161
+ visible = entry.isIntersecting;
162
+ if (visible)
163
+ render();
164
+ sync();
165
+ });
166
+ own(() => visibilityObserver.disconnect());
167
+ const resizeObserver = new ResizeObserver(resize);
168
+ own(() => resizeObserver.disconnect());
169
+ visibilityObserver.observe(host);
170
+ resizeObserver.observe(host);
171
+ host.addEventListener("pointermove", trackPointer);
172
+ own(() => host.removeEventListener("pointermove", trackPointer));
173
+ host.addEventListener("pointerleave", resetPointer);
174
+ own(() => host.removeEventListener("pointerleave", resetPointer));
175
+ document.addEventListener("visibilitychange", sync);
176
+ own(() => document.removeEventListener("visibilitychange", sync));
177
+ renderer.domElement.addEventListener("webglcontextlost", loseContext);
178
+ own(() => renderer.domElement.removeEventListener("webglcontextlost", loseContext));
179
+ renderer.domElement.addEventListener("webglcontextrestored", restoreContext);
180
+ own(() => renderer.domElement.removeEventListener("webglcontextrestored", restoreContext));
181
+ resize();
126
182
  sync();
127
- });
128
- const resizeObserver = new ResizeObserver(resize);
129
- visibilityObserver.observe(host);
130
- resizeObserver.observe(host);
131
- host.addEventListener("pointermove", trackPointer);
132
- host.addEventListener("pointerleave", resetPointer);
133
- document.addEventListener("visibilitychange", sync);
134
- renderer.domElement.addEventListener("webglcontextlost", loseContext);
135
- renderer.domElement.addEventListener("webglcontextrestored", restoreContext);
136
- resize();
137
- sync();
138
- return () => {
139
- running = false;
140
- syncRef.current = undefined;
141
- cancelAnimationFrame(frame);
142
- visibilityObserver.disconnect();
143
- resizeObserver.disconnect();
144
- host.removeEventListener("pointermove", trackPointer);
145
- host.removeEventListener("pointerleave", resetPointer);
146
- document.removeEventListener("visibilitychange", sync);
147
- renderer.domElement.removeEventListener("webglcontextlost", loseContext);
148
- renderer.domElement.removeEventListener("webglcontextrestored", restoreContext);
149
- stage.dispose();
150
- environmentMap.dispose();
151
- renderer.dispose();
152
- renderer.domElement.remove();
153
- };
183
+ return cleanup;
184
+ }
185
+ catch (error) {
186
+ cleanup();
187
+ host.dataset.state = "unavailable";
188
+ console.error("Contour stage initialization failed", error);
189
+ return;
190
+ }
154
191
  }, [hostRef]);
155
192
  }
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  /** Toast context: lets any component queue a notification rendered by ToastProvider. */
2
3
  import { createContext, useContext } from "react";
3
4
  export const ToastContext = createContext(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abuzareal/contour-ui",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "description": "Editorial React components, themes, restrained motion, and optional Three.js objects.",
6
6
  "type": "module",
@@ -10,7 +10,12 @@
10
10
  "LICENSE",
11
11
  "CHANGELOG.md",
12
12
  "SECURITY.md",
13
- "RELEASING.md"
13
+ "RELEASING.md",
14
+ "CONTRIBUTING.md",
15
+ "CODE_OF_CONDUCT.md",
16
+ "NEXTJS.md",
17
+ "CONTRACTS.md",
18
+ "PUBLIC-API.md"
14
19
  ],
15
20
  "sideEffects": [
16
21
  "**/*.css"
@@ -18,19 +23,23 @@
18
23
  "exports": {
19
24
  ".": {
20
25
  "types": "./dist/index.d.ts",
21
- "import": "./dist/index.js"
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
22
28
  },
23
29
  "./three": {
24
30
  "types": "./dist/three.d.ts",
25
- "import": "./dist/three.js"
31
+ "import": "./dist/three.js",
32
+ "default": "./dist/three.js"
26
33
  },
27
34
  "./scroll": {
28
35
  "types": "./dist/scroll.d.ts",
29
- "import": "./dist/scroll.js"
36
+ "import": "./dist/scroll.js",
37
+ "default": "./dist/scroll.js"
30
38
  },
31
39
  "./theme-bootstrap": {
32
40
  "types": "./dist/lib/themeBootstrap.d.ts",
33
- "import": "./dist/lib/themeBootstrap.js"
41
+ "import": "./dist/lib/themeBootstrap.js",
42
+ "default": "./dist/lib/themeBootstrap.js"
34
43
  },
35
44
  "./styles.css": "./dist/styles.css",
36
45
  "./reset.css": "./dist/reset.css",
@@ -46,10 +55,12 @@
46
55
  "test:browser": "node --test tests/*.browser.mjs",
47
56
  "check:package": "node scripts/check-package.mjs",
48
57
  "validate": "npm run typecheck && npm run lint && npm run build && npm test && npm run test:browser && npm run check:package",
49
- "prepublishOnly": "npm run validate && npm run test:compat",
58
+ "prepublishOnly": "node scripts/verify-release.mjs",
50
59
  "test:consumer": "node scripts/test-consumer.mjs",
51
60
  "test:compat": "node scripts/test-compat.mjs",
52
- "test:optional": "node scripts/test-optional-consumer.mjs"
61
+ "test:optional": "node scripts/test-optional-consumer.mjs",
62
+ "test:next": "npm run build && node scripts/test-next.mjs",
63
+ "verify:release": "node scripts/verify-release.mjs"
53
64
  },
54
65
  "dependencies": {
55
66
  "@fontsource-variable/dm-sans": "^5.3.0",