@abuzareal/contour-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +111 -0
  4. package/RELEASING.md +37 -0
  5. package/SECURITY.md +11 -0
  6. package/dist/components/hero/SceneBoundary.d.ts +14 -0
  7. package/dist/components/hero/SceneBoundary.js +11 -0
  8. package/dist/components/hero/Sculpture.d.ts +4 -0
  9. package/dist/components/hero/Sculpture.js +54 -0
  10. package/dist/components/three/ObjectStudy.d.ts +7 -0
  11. package/dist/components/three/ObjectStudy.js +65 -0
  12. package/dist/components/ui/Alert.d.ts +13 -0
  13. package/dist/components/ui/Alert.js +15 -0
  14. package/dist/components/ui/Avatar.d.ts +7 -0
  15. package/dist/components/ui/Avatar.js +12 -0
  16. package/dist/components/ui/Badge.d.ts +11 -0
  17. package/dist/components/ui/Badge.js +5 -0
  18. package/dist/components/ui/BrandMark.d.ts +7 -0
  19. package/dist/components/ui/BrandMark.js +5 -0
  20. package/dist/components/ui/Breadcrumbs.d.ts +10 -0
  21. package/dist/components/ui/Breadcrumbs.js +8 -0
  22. package/dist/components/ui/Button.d.ts +12 -0
  23. package/dist/components/ui/Button.js +6 -0
  24. package/dist/components/ui/Card.d.ts +13 -0
  25. package/dist/components/ui/Card.js +5 -0
  26. package/dist/components/ui/Checkbox.d.ts +9 -0
  27. package/dist/components/ui/Checkbox.js +14 -0
  28. package/dist/components/ui/CountUp.d.ts +9 -0
  29. package/dist/components/ui/CountUp.js +48 -0
  30. package/dist/components/ui/DataTable.d.ts +16 -0
  31. package/dist/components/ui/DataTable.js +24 -0
  32. package/dist/components/ui/DepthButton.d.ts +6 -0
  33. package/dist/components/ui/DepthButton.js +4 -0
  34. package/dist/components/ui/Dialog.d.ts +12 -0
  35. package/dist/components/ui/Dialog.js +27 -0
  36. package/dist/components/ui/Disclosure.d.ts +11 -0
  37. package/dist/components/ui/Disclosure.js +5 -0
  38. package/dist/components/ui/Divider.d.ts +5 -0
  39. package/dist/components/ui/Divider.js +6 -0
  40. package/dist/components/ui/DropdownMenu.d.ts +14 -0
  41. package/dist/components/ui/DropdownMenu.js +38 -0
  42. package/dist/components/ui/EmptyState.d.ts +9 -0
  43. package/dist/components/ui/EmptyState.js +4 -0
  44. package/dist/components/ui/ExternalLink.d.ts +4 -0
  45. package/dist/components/ui/ExternalLink.js +5 -0
  46. package/dist/components/ui/Field.d.ts +15 -0
  47. package/dist/components/ui/Field.js +6 -0
  48. package/dist/components/ui/FlipCard.d.ts +8 -0
  49. package/dist/components/ui/FlipCard.js +9 -0
  50. package/dist/components/ui/IconButton.d.ts +11 -0
  51. package/dist/components/ui/IconButton.js +5 -0
  52. package/dist/components/ui/Kbd.d.ts +5 -0
  53. package/dist/components/ui/Kbd.js +4 -0
  54. package/dist/components/ui/Magnetic.d.ts +8 -0
  55. package/dist/components/ui/Magnetic.js +19 -0
  56. package/dist/components/ui/Marquee.d.ts +7 -0
  57. package/dist/components/ui/Marquee.js +7 -0
  58. package/dist/components/ui/Pagination.d.ts +7 -0
  59. package/dist/components/ui/Pagination.js +15 -0
  60. package/dist/components/ui/Popover.d.ts +10 -0
  61. package/dist/components/ui/Popover.js +18 -0
  62. package/dist/components/ui/ProgressBar.d.ts +7 -0
  63. package/dist/components/ui/ProgressBar.js +6 -0
  64. package/dist/components/ui/RadioGroup.d.ts +14 -0
  65. package/dist/components/ui/RadioGroup.js +11 -0
  66. package/dist/components/ui/Reveal.d.ts +9 -0
  67. package/dist/components/ui/Reveal.js +24 -0
  68. package/dist/components/ui/RevealHeading.d.ts +12 -0
  69. package/dist/components/ui/RevealHeading.js +4 -0
  70. package/dist/components/ui/ScrollProgress.d.ts +1 -0
  71. package/dist/components/ui/ScrollProgress.js +27 -0
  72. package/dist/components/ui/SectionLabel.d.ts +8 -0
  73. package/dist/components/ui/SectionLabel.js +5 -0
  74. package/dist/components/ui/SegmentedControl.d.ts +10 -0
  75. package/dist/components/ui/SegmentedControl.js +20 -0
  76. package/dist/components/ui/SelectField.d.ts +13 -0
  77. package/dist/components/ui/SelectField.js +10 -0
  78. package/dist/components/ui/Skeleton.d.ts +6 -0
  79. package/dist/components/ui/Skeleton.js +5 -0
  80. package/dist/components/ui/Slider.d.ts +12 -0
  81. package/dist/components/ui/Slider.js +10 -0
  82. package/dist/components/ui/SocialIcons.d.ts +6 -0
  83. package/dist/components/ui/SocialIcons.js +7 -0
  84. package/dist/components/ui/Spinner.d.ts +6 -0
  85. package/dist/components/ui/Spinner.js +8 -0
  86. package/dist/components/ui/Stat.d.ts +8 -0
  87. package/dist/components/ui/Stat.js +4 -0
  88. package/dist/components/ui/Stepper.d.ts +10 -0
  89. package/dist/components/ui/Stepper.js +15 -0
  90. package/dist/components/ui/Switch.d.ts +8 -0
  91. package/dist/components/ui/Switch.js +7 -0
  92. package/dist/components/ui/Tabs.d.ts +13 -0
  93. package/dist/components/ui/Tabs.js +41 -0
  94. package/dist/components/ui/Tag.d.ts +5 -0
  95. package/dist/components/ui/Tag.js +8 -0
  96. package/dist/components/ui/TextArea.d.ts +10 -0
  97. package/dist/components/ui/TextArea.js +23 -0
  98. package/dist/components/ui/TextField.d.ts +12 -0
  99. package/dist/components/ui/TextField.js +13 -0
  100. package/dist/components/ui/ThemeToggle.d.ts +5 -0
  101. package/dist/components/ui/ThemeToggle.js +9 -0
  102. package/dist/components/ui/TiltCard.d.ts +8 -0
  103. package/dist/components/ui/TiltCard.js +23 -0
  104. package/dist/components/ui/Timeline.d.ts +9 -0
  105. package/dist/components/ui/Timeline.js +6 -0
  106. package/dist/components/ui/ToastProvider.d.ts +5 -0
  107. package/dist/components/ui/ToastProvider.js +29 -0
  108. package/dist/components/ui/Tooltip.d.ts +11 -0
  109. package/dist/components/ui/Tooltip.js +16 -0
  110. package/dist/fonts.css +3 -0
  111. package/dist/hooks/useClipboardCopy.d.ts +5 -0
  112. package/dist/hooks/useClipboardCopy.js +21 -0
  113. package/dist/hooks/useDismiss.d.ts +3 -0
  114. package/dist/hooks/useDismiss.js +22 -0
  115. package/dist/hooks/useHeaderScroll.d.ts +7 -0
  116. package/dist/hooks/useHeaderScroll.js +40 -0
  117. package/dist/hooks/useIdleTrigger.d.ts +1 -0
  118. package/dist/hooks/useIdleTrigger.js +19 -0
  119. package/dist/hooks/useInitialHashScroll.d.ts +1 -0
  120. package/dist/hooks/useInitialHashScroll.js +28 -0
  121. package/dist/hooks/useMobileMenu.d.ts +3 -0
  122. package/dist/hooks/useMobileMenu.js +34 -0
  123. package/dist/hooks/useMotionPreference.d.ts +1 -0
  124. package/dist/hooks/useMotionPreference.js +13 -0
  125. package/dist/hooks/usePrintPreparation.d.ts +1 -0
  126. package/dist/hooks/usePrintPreparation.js +29 -0
  127. package/dist/hooks/useScrollSpy.d.ts +1 -0
  128. package/dist/hooks/useScrollSpy.js +36 -0
  129. package/dist/hooks/useSmoothScroll.d.ts +1 -0
  130. package/dist/hooks/useSmoothScroll.js +18 -0
  131. package/dist/hooks/useTheme.d.ts +5 -0
  132. package/dist/hooks/useTheme.js +98 -0
  133. package/dist/hooks/useThreeStage.d.ts +28 -0
  134. package/dist/hooks/useThreeStage.js +155 -0
  135. package/dist/hooks/useToast.d.ts +11 -0
  136. package/dist/hooks/useToast.js +9 -0
  137. package/dist/index.d.ts +129 -0
  138. package/dist/index.js +68 -0
  139. package/dist/kit/actions.css +209 -0
  140. package/dist/kit/data-display.css +496 -0
  141. package/dist/kit/effects.css +144 -0
  142. package/dist/kit/feedback.css +316 -0
  143. package/dist/kit/forms.css +363 -0
  144. package/dist/kit/index.css +9 -0
  145. package/dist/kit/navigation.css +262 -0
  146. package/dist/kit/overlays.css +299 -0
  147. package/dist/lib/a11y.d.ts +3 -0
  148. package/dist/lib/a11y.js +3 -0
  149. package/dist/lib/classNames.d.ts +6 -0
  150. package/dist/lib/classNames.js +4 -0
  151. package/dist/lib/format.d.ts +3 -0
  152. package/dist/lib/format.js +3 -0
  153. package/dist/lib/keyboard.d.ts +8 -0
  154. package/dist/lib/keyboard.js +22 -0
  155. package/dist/lib/motion.d.ts +3 -0
  156. package/dist/lib/motion.js +4 -0
  157. package/dist/lib/safeHref.d.ts +2 -0
  158. package/dist/lib/safeHref.js +14 -0
  159. package/dist/lib/storage.d.ts +10 -0
  160. package/dist/lib/storage.js +25 -0
  161. package/dist/lib/themeBootstrap.d.ts +6 -0
  162. package/dist/lib/themeBootstrap.js +19 -0
  163. package/dist/lib/threeMaterials.d.ts +9 -0
  164. package/dist/lib/threeMaterials.js +62 -0
  165. package/dist/lib/threeStudies.d.ts +16 -0
  166. package/dist/lib/threeStudies.js +39 -0
  167. package/dist/motion.css +59 -0
  168. package/dist/reset.css +180 -0
  169. package/dist/scroll.d.ts +1 -0
  170. package/dist/scroll.js +1 -0
  171. package/dist/styles.css +23 -0
  172. package/dist/theme-toggle.css +38 -0
  173. package/dist/three.d.ts +8 -0
  174. package/dist/three.js +5 -0
  175. package/dist/tokens.css +127 -0
  176. package/dist/types.d.ts +6 -0
  177. package/dist/types.js +1 -0
  178. package/dist/ui.css +178 -0
  179. package/package.json +101 -0
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "../../lib/classNames.js";
3
+ export default function Skeleton({ shape = "line", width, height, }) {
4
+ return (_jsx("span", { "aria-hidden": "true", className: cx("skeleton", `skeleton-${shape}`), style: { width, height } }));
5
+ }
@@ -0,0 +1,12 @@
1
+ export type SliderProps = {
2
+ label: string;
3
+ value: number;
4
+ onChange: (value: number) => void;
5
+ min?: number;
6
+ max?: number;
7
+ step?: number;
8
+ /** Formats the readout, e.g. (value) => `${value}%`. */
9
+ format?: (value: number) => string;
10
+ disabled?: boolean;
11
+ };
12
+ export default function Slider({ label, value, onChange, min, max, step, format, disabled, }: SliderProps): import("react").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Range slider with a filled acid track and a live value readout. */
3
+ import { useId } from "react";
4
+ export default function Slider({ label, value, onChange, min = 0, max = 100, step = 1, format = String, disabled, }) {
5
+ const id = useId();
6
+ const progress = max > min
7
+ ? Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100))
8
+ : 0;
9
+ return (_jsxs("div", { className: "slider", children: [_jsxs("div", { className: "slider-header mono", children: [_jsx("label", { htmlFor: id, className: "field-label", children: label }), _jsx("output", { htmlFor: id, children: format(value) })] }), _jsx("input", { id: id, type: "range", min: min, max: max, step: step, value: value, disabled: disabled, "aria-valuetext": format(value), style: { "--progress": `${progress}%` }, onChange: (event) => onChange(Number(event.target.value)) })] }));
10
+ }
@@ -0,0 +1,6 @@
1
+ /** Brand marks for social profiles (Lucide no longer ships brand icons). */
2
+ export type SocialIconProps = {
3
+ size?: number;
4
+ };
5
+ export declare function GitHubIcon({ size }: SocialIconProps): import("react").JSX.Element;
6
+ export declare function LinkedInIcon({ size }: SocialIconProps): import("react").JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export function GitHubIcon({ size = 17 }) {
3
+ return (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { fill: "currentColor", d: "M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2 0 1.9 1.2 1.9 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.7 1.6.2 2.9.1 3.2.8.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1.1.9 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3" }) }));
4
+ }
5
+ export function LinkedInIcon({ size = 17 }) {
6
+ return (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { fill: "currentColor", d: "M20.4 20.5h-3.6v-5.6c0-1.3 0-3-1.8-3s-2.1 1.4-2.1 2.9v5.7H9.3V9h3.4v1.6c.5-.9 1.6-1.8 3.4-1.8 3.6 0 4.3 2.4 4.3 5.5zM5.3 7.4a2.1 2.1 0 1 1 0-4.2 2.1 2.1 0 0 1 0 4.2M7.1 20.5H3.5V9h3.6zM22.2 0H1.8C.8 0 0 .8 0 1.7v20.6c0 .9.8 1.7 1.8 1.7h20.4c1 0 1.8-.8 1.8-1.7V1.7C24 .8 23.2 0 22.2 0" }) }));
7
+ }
@@ -0,0 +1,6 @@
1
+ export type SpinnerProps = {
2
+ size?: "sm" | "md" | "lg";
3
+ label?: string;
4
+ className?: string;
5
+ };
6
+ export default function Spinner({ size, label, className, }: SpinnerProps): import("react").JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Brand asterisk that spins as a loading indicator; announces its label to assistive tech. */
3
+ import { Asterisk } from "lucide-react";
4
+ import { cx } from "../../lib/classNames.js";
5
+ const iconSizes = { sm: 16, md: 24, lg: 40 };
6
+ export default function Spinner({ size = "md", label = "Loading", className, }) {
7
+ return (_jsxs("span", { role: "status", className: cx("spinner", `spinner-${size}`, className), children: [_jsx(Asterisk, { size: iconSizes[size], strokeWidth: 1.6, "aria-hidden": "true" }), _jsx("span", { className: "visually-hidden", children: label })] }));
8
+ }
@@ -0,0 +1,8 @@
1
+ /** Headline number with an olive suffix and a mono caption, as in the About facts. */
2
+ import type { ReactNode } from "react";
3
+ export type StatProps = {
4
+ value: ReactNode;
5
+ label: string;
6
+ suffix?: string;
7
+ };
8
+ export default function Stat({ value, label, suffix }: StatProps): import("react").JSX.Element;
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export default function Stat({ value, label, suffix }) {
3
+ return (_jsxs("div", { className: "stat", children: [_jsxs("strong", { children: [value, suffix && _jsx("span", { className: "stat-suffix", children: suffix })] }), _jsx("span", { className: "mono", children: label })] }));
4
+ }
@@ -0,0 +1,10 @@
1
+ export type StepperProps = {
2
+ steps: {
3
+ label: string;
4
+ description?: string;
5
+ }[];
6
+ /** Zero-based index of the step in progress. */
7
+ current: number;
8
+ label?: string;
9
+ };
10
+ export default function Stepper({ steps, current, label, }: StepperProps): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Numbered progress steps joined by a rule; completed steps show a check. */
3
+ import { Check } from "lucide-react";
4
+ import { cx } from "../../lib/classNames.js";
5
+ import { formatIndex } from "../../lib/format.js";
6
+ export default function Stepper({ steps, current, label = "Progress", }) {
7
+ return (_jsx("ol", { className: "stepper", "aria-label": label, children: steps.map((step, index) => {
8
+ const state = index < current
9
+ ? "complete"
10
+ : index === current
11
+ ? "current"
12
+ : "upcoming";
13
+ return (_jsxs("li", { className: cx("stepper-step", `stepper-${state}`), "aria-current": state === "current" ? "step" : undefined, children: [_jsx("span", { className: "stepper-marker mono", "aria-hidden": "true", children: state === "complete" ? (_jsx(Check, { size: 14, strokeWidth: 2.5 })) : (formatIndex(index + 1)) }), _jsxs("span", { className: "stepper-text", children: [_jsx("span", { className: "stepper-label", children: step.label }), step.description && (_jsx("span", { className: "stepper-description mono", children: step.description })), _jsxs("span", { className: "visually-hidden", children: ["(", state, ")"] })] })] }, step.label));
14
+ }) }));
15
+ }
@@ -0,0 +1,8 @@
1
+ export type SwitchProps = {
2
+ label: string;
3
+ checked: boolean;
4
+ onChange: (checked: boolean) => void;
5
+ description?: string;
6
+ disabled?: boolean;
7
+ };
8
+ export default function Switch({ label, checked, onChange, description, disabled, }: SwitchProps): import("react").JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** On/off switch (role="switch") with a pill track that fills acid when on. */
3
+ import { useId } from "react";
4
+ export default function Switch({ label, checked, onChange, description, disabled, }) {
5
+ const id = useId();
6
+ return (_jsxs("div", { className: "switch", children: [_jsx("button", { id: id, type: "button", role: "switch", "aria-checked": checked, "aria-describedby": description ? `${id}-description` : undefined, disabled: disabled, className: "switch-track", onClick: () => onChange(!checked), children: _jsx("span", { className: "switch-thumb" }) }), _jsxs("label", { htmlFor: id, className: "choice-text", children: [_jsx("span", { className: "choice-label", children: label }), description && (_jsx("span", { id: `${id}-description`, className: "choice-description", children: description }))] })] }));
7
+ }
@@ -0,0 +1,13 @@
1
+ /** Tabs with a sliding underline indicator, arrow-key navigation, and linked panels. */
2
+ import { type ReactNode } from "react";
3
+ export type TabItem = {
4
+ id: string;
5
+ label: string;
6
+ content: ReactNode;
7
+ };
8
+ export type TabsProps = {
9
+ label: string;
10
+ tabs: TabItem[];
11
+ defaultTabId?: string;
12
+ };
13
+ export default function Tabs({ label, tabs, defaultTabId }: TabsProps): import("react").JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Tabs with a sliding underline indicator, arrow-key navigation, and linked panels. */
3
+ import { useId, useLayoutEffect, useRef, useState, } from "react";
4
+ import { nextIndexForKey } from "../../lib/keyboard.js";
5
+ export default function Tabs({ label, tabs, defaultTabId }) {
6
+ const baseId = useId();
7
+ const [activeId, setActiveId] = useState(defaultTabId ?? tabs[0]?.id ?? "");
8
+ const [indicator, setIndicator] = useState({ left: 0, width: 0 });
9
+ const tabRefs = useRef([]);
10
+ const activeIndex = Math.max(0, tabs.findIndex((tab) => tab.id === activeId));
11
+ const selectedId = tabs[activeIndex]?.id;
12
+ useLayoutEffect(() => {
13
+ const measure = () => {
14
+ const tab = tabRefs.current[activeIndex];
15
+ if (tab)
16
+ setIndicator({ left: tab.offsetLeft, width: tab.offsetWidth });
17
+ };
18
+ measure();
19
+ window.addEventListener("resize", measure);
20
+ return () => window.removeEventListener("resize", measure);
21
+ }, [activeIndex]);
22
+ const select = (index) => {
23
+ setActiveId(tabs[index].id);
24
+ tabRefs.current[index]?.focus();
25
+ };
26
+ return (_jsxs("div", { className: "tabs", children: [_jsx("div", { className: "tabs-list", role: "tablist", "aria-label": label, style: {
27
+ "--indicator-left": `${indicator.left}px`,
28
+ "--indicator-width": `${indicator.width}px`,
29
+ }, children: tabs.map((tab, index) => {
30
+ const selected = tab.id === selectedId;
31
+ return (_jsx("button", { ref: (element) => {
32
+ tabRefs.current[index] = element;
33
+ }, id: `${baseId}-tab-${tab.id}`, type: "button", role: "tab", "aria-selected": selected, "aria-controls": `${baseId}-panel-${tab.id}`, tabIndex: selected ? 0 : -1, className: "tabs-tab", onClick: () => setActiveId(tab.id), onKeyDown: (event) => {
34
+ const next = nextIndexForKey(event.key, index, tabs.length);
35
+ if (next === null)
36
+ return;
37
+ event.preventDefault();
38
+ select(next);
39
+ }, children: tab.label }, tab.id));
40
+ }) }), tabs.map((tab) => (_jsx("div", { id: `${baseId}-panel-${tab.id}`, role: "tabpanel", "aria-labelledby": `${baseId}-tab-${tab.id}`, hidden: tab.id !== selectedId, tabIndex: 0, className: "tabs-panel", children: tab.content }, tab.id)))] }));
41
+ }
@@ -0,0 +1,5 @@
1
+ export type TagProps = {
2
+ children: string;
3
+ onRemove?: () => void;
4
+ };
5
+ export default function Tag({ children, onRemove }: TagProps): import("react").JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Mono "+ tag" label (as in project notes); becomes a removable chip when given onRemove. */
3
+ import { X } from "lucide-react";
4
+ export default function Tag({ children, onRemove }) {
5
+ if (!onRemove)
6
+ return _jsx("span", { className: "tag mono", children: children });
7
+ return (_jsxs("span", { className: "tag tag-removable mono", children: [children, _jsx("button", { type: "button", className: "tag-remove", "aria-label": `Remove ${children}`, onClick: onRemove, children: _jsx(X, { size: 12 }) })] }));
8
+ }
@@ -0,0 +1,10 @@
1
+ /** Multi-line text input with optional auto-resize and character counter. */
2
+ import { type TextareaHTMLAttributes } from "react";
3
+ export type TextAreaProps = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "id"> & {
4
+ label: string;
5
+ hint?: string;
6
+ error?: string;
7
+ /** Grows with its content instead of scrolling. */
8
+ autoResize?: boolean;
9
+ };
10
+ export default function TextArea({ label, hint, error, autoResize, maxLength, onChange, rows, ...rest }: TextAreaProps): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /** Multi-line text input with optional auto-resize and character counter. */
3
+ import { useId, useLayoutEffect, useRef, useState, } from "react";
4
+ import { fieldDescribedBy } from "../../lib/a11y.js";
5
+ import Field from "./Field.js";
6
+ export default function TextArea({ label, hint, error, autoResize = false, maxLength, onChange, rows = 3, ...rest }) {
7
+ const id = useId();
8
+ const ref = useRef(null);
9
+ const [length, setLength] = useState(String(rest.value ?? rest.defaultValue ?? "").length);
10
+ const fitContent = () => {
11
+ const element = ref.current;
12
+ if (!autoResize || !element)
13
+ return;
14
+ element.style.height = "auto";
15
+ element.style.height = `${element.scrollHeight}px`;
16
+ };
17
+ useLayoutEffect(fitContent);
18
+ return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, disabled: rest.disabled, counter: maxLength ? `${length}/${maxLength}` : undefined, children: _jsx("div", { className: "field-control", children: _jsx("textarea", { ...rest, ref: ref, id: id, rows: rows, maxLength: maxLength, "data-auto-resize": autoResize || undefined, "aria-invalid": error ? true : undefined, "aria-describedby": fieldDescribedBy(id, hint, error), onChange: (event) => {
19
+ setLength(event.target.value.length);
20
+ fitContent();
21
+ onChange?.(event);
22
+ } }) }) }));
23
+ }
@@ -0,0 +1,12 @@
1
+ /** Single-line text input with underline styling, optional icon/adornment, and character counter. */
2
+ import { type InputHTMLAttributes, type ReactNode } from "react";
3
+ export type TextFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "id"> & {
4
+ label: string;
5
+ hint?: string;
6
+ error?: string;
7
+ hideLabel?: boolean;
8
+ leadingIcon?: ReactNode;
9
+ /** Content after the input, e.g. a unit or an icon button. */
10
+ trailing?: ReactNode;
11
+ };
12
+ export default function TextField({ label, hint, error, hideLabel, leadingIcon, trailing, maxLength, onChange, ...rest }: TextFieldProps): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Single-line text input with underline styling, optional icon/adornment, and character counter. */
3
+ import { useId, useState, } from "react";
4
+ import { fieldDescribedBy } from "../../lib/a11y.js";
5
+ import Field from "./Field.js";
6
+ export default function TextField({ label, hint, error, hideLabel, leadingIcon, trailing, maxLength, onChange, ...rest }) {
7
+ const id = useId();
8
+ const [length, setLength] = useState(String(rest.value ?? rest.defaultValue ?? "").length);
9
+ return (_jsx(Field, { id: id, label: label, hint: hint, error: error, required: rest.required, hideLabel: hideLabel, disabled: rest.disabled, counter: maxLength ? `${length}/${maxLength}` : undefined, children: _jsxs("div", { className: "field-control", children: [leadingIcon && _jsx("span", { className: "field-icon", children: leadingIcon }), _jsx("input", { ...rest, id: id, maxLength: maxLength, "aria-invalid": error ? true : undefined, "aria-describedby": fieldDescribedBy(id, hint, error), onChange: (event) => {
10
+ setLength(event.target.value.length);
11
+ onChange?.(event);
12
+ } }), trailing && _jsx("span", { className: "field-trailing", children: trailing })] }) }));
13
+ }
@@ -0,0 +1,5 @@
1
+ export type ThemeToggleProps = {
2
+ /** When false the theme switches instantly instead of animating. */
3
+ animate: boolean;
4
+ };
5
+ export default function ThemeToggle({ animate }: ThemeToggleProps): import("react").JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Header button that switches between the light and dark themes. */
3
+ import { Moon, Sun } from "lucide-react";
4
+ import useTheme from "../../hooks/useTheme.js";
5
+ export default function ThemeToggle({ animate }) {
6
+ const { theme, toggle } = useTheme(animate);
7
+ const dark = theme === "dark";
8
+ return (_jsxs("button", { className: "theme-toggle", "aria-label": "Dark theme", "aria-pressed": dark, title: dark ? "Switch to light theme" : "Switch to dark theme", onClick: (event) => toggle(event.currentTarget), children: [_jsx(Moon, { className: "theme-icon-moon", size: 16, "aria-hidden": "true" }), _jsx(Sun, { className: "theme-icon-sun", size: 17, "aria-hidden": "true" })] }));
9
+ }
@@ -0,0 +1,8 @@
1
+ /** Card that tilts in 3D toward a fine pointer with a moving glare highlight. */
2
+ import { type ReactNode } from "react";
3
+ export type TiltCardProps = {
4
+ children: ReactNode;
5
+ /** Maximum rotation in degrees. */
6
+ maxTilt?: number;
7
+ };
8
+ export default function TiltCard({ children, maxTilt }: TiltCardProps): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Card that tilts in 3D toward a fine pointer with a moving glare highlight. */
3
+ import { useRef } from "react";
4
+ import { motionDisabledFor } from "../../lib/motion.js";
5
+ export default function TiltCard({ children, maxTilt = 12 }) {
6
+ const ref = useRef(null);
7
+ return (_jsx("div", { className: "tilt-stage", children: _jsxs("div", { ref: ref, className: "tilt-card", onPointerMove: (event) => {
8
+ const element = ref.current;
9
+ if (!element ||
10
+ event.pointerType !== "mouse" ||
11
+ motionDisabledFor(element))
12
+ return;
13
+ const bounds = element.getBoundingClientRect();
14
+ const x = (event.clientX - bounds.left) / bounds.width;
15
+ const y = (event.clientY - bounds.top) / bounds.height;
16
+ element.style.setProperty("--tilt-x", `${(0.5 - y) * maxTilt * 2}deg`);
17
+ element.style.setProperty("--tilt-y", `${(x - 0.5) * maxTilt * 2}deg`);
18
+ element.style.setProperty("--glare-x", `${x * 100}%`);
19
+ element.style.setProperty("--glare-y", `${y * 100}%`);
20
+ }, onPointerLeave: () => {
21
+ ["--tilt-x", "--tilt-y", "--glare-x", "--glare-y"].forEach((name) => ref.current?.style.removeProperty(name));
22
+ }, children: [children, _jsx("span", { className: "tilt-glare", "aria-hidden": "true" })] }) }));
23
+ }
@@ -0,0 +1,9 @@
1
+ export type TimelineProps = {
2
+ entries: {
3
+ date: string;
4
+ title: string;
5
+ body?: string;
6
+ current?: boolean;
7
+ }[];
8
+ };
9
+ export default function Timeline({ entries }: TimelineProps): import("react").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Vertical timeline of dated entries joined by a rule, with an acid marker for the current one. */
3
+ import { cx } from "../../lib/classNames.js";
4
+ export default function Timeline({ entries }) {
5
+ return (_jsx("ol", { className: "timeline", children: entries.map((entry) => (_jsxs("li", { className: cx("timeline-entry", entry.current && "timeline-current"), children: [_jsx("span", { className: "timeline-date mono", children: entry.date }), _jsx("span", { className: "timeline-title", children: entry.title }), entry.body && _jsx("span", { className: "timeline-body", children: entry.body })] }, `${entry.date}-${entry.title}`))) }));
6
+ }
@@ -0,0 +1,5 @@
1
+ /** Renders queued toasts in a polite live region; each auto-dismisses unless hovered or focused. */
2
+ import { type ReactNode } from "react";
3
+ export default function ToastProvider({ children }: {
4
+ children: ReactNode;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Renders queued toasts in a polite live region; each auto-dismisses unless hovered or focused. */
3
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
4
+ import { X } from "lucide-react";
5
+ import { ToastContext, } from "../../hooks/useToast.js";
6
+ import { cx } from "../../lib/classNames.js";
7
+ const autoDismissAfter = 5000;
8
+ function Toast({ toast, onDismiss, }) {
9
+ const [paused, setPaused] = useState(false);
10
+ useEffect(() => {
11
+ if (paused)
12
+ return;
13
+ const timer = window.setTimeout(() => onDismiss(toast.id), autoDismissAfter);
14
+ return () => window.clearTimeout(timer);
15
+ }, [paused, toast.id, onDismiss]);
16
+ return (_jsxs("li", { className: cx("toast", `toast-${toast.tone ?? "neutral"}`), onPointerEnter: () => setPaused(true), onPointerLeave: () => setPaused(false), onFocus: () => setPaused(true), onBlur: () => setPaused(false), children: [_jsx("span", { className: "toast-bar", "aria-hidden": "true" }), _jsxs("div", { className: "toast-content", children: [_jsx("strong", { children: toast.title }), toast.message && _jsx("p", { children: toast.message })] }), _jsx("button", { type: "button", className: "toast-close", "aria-label": "Dismiss notification", onClick: () => onDismiss(toast.id), children: _jsx(X, { size: 15 }) })] }));
17
+ }
18
+ export default function ToastProvider({ children }) {
19
+ const [toasts, setToasts] = useState([]);
20
+ const nextId = useRef(0);
21
+ const dismiss = useCallback((id) => setToasts((current) => current.filter((toast) => toast.id !== id)), []);
22
+ const value = useMemo(() => ({
23
+ show: (toast) => setToasts((current) => [
24
+ ...current.slice(-2),
25
+ { ...toast, id: (nextId.current += 1) },
26
+ ]),
27
+ }), []);
28
+ return (_jsxs(ToastContext.Provider, { value: value, children: [children, _jsx("ol", { className: "toast-viewport", "aria-live": "polite", "aria-label": "Notifications", children: toasts.map((toast) => (_jsx(Toast, { toast: toast, onDismiss: dismiss }, toast.id))) })] }));
29
+ }
@@ -0,0 +1,11 @@
1
+ /** Ink tooltip shown on hover or focus, dismissible with Escape, described via aria-describedby. */
2
+ import { type ReactElement } from "react";
3
+ export type TooltipProps = {
4
+ content: string;
5
+ /** A single focusable element, e.g. a button. */
6
+ children: ReactElement<{
7
+ "aria-describedby"?: string;
8
+ }>;
9
+ placement?: "top" | "bottom" | "left" | "right";
10
+ };
11
+ export default function Tooltip({ content, children, placement, }: TooltipProps): import("react").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Ink tooltip shown on hover or focus, dismissible with Escape, described via aria-describedby. */
3
+ import { cloneElement, useId, useState } from "react";
4
+ import { cx } from "../../lib/classNames.js";
5
+ export default function Tooltip({ content, children, placement = "top", }) {
6
+ const id = useId();
7
+ const [open, setOpen] = useState(false);
8
+ return (_jsxs("span", { className: "tooltip-anchor", onPointerEnter: () => setOpen(true), onPointerLeave: () => setOpen(false), onFocus: () => setOpen(true), onBlur: () => setOpen(false), onKeyDown: (event) => {
9
+ if (event.key === "Escape")
10
+ setOpen(false);
11
+ }, children: [cloneElement(children, {
12
+ "aria-describedby": [children.props["aria-describedby"], id]
13
+ .filter(Boolean)
14
+ .join(" "),
15
+ }), _jsx("span", { id: id, role: "tooltip", className: cx("tooltip mono", `tooltip-${placement}`, open && "is-open"), children: content })] }));
16
+ }
package/dist/fonts.css ADDED
@@ -0,0 +1,3 @@
1
+ @import "@fontsource-variable/dm-sans/wght.css";
2
+ @import "@fontsource-variable/space-grotesk/wght.css";
3
+ @import "@fontsource/dm-mono/400.css";
@@ -0,0 +1,5 @@
1
+ export type CopyStatus = "idle" | "copying" | "copied" | "error";
2
+ export default function useClipboardCopy(text: string): {
3
+ status: CopyStatus;
4
+ copy: () => Promise<void>;
5
+ };
@@ -0,0 +1,21 @@
1
+ /** Copies text to the clipboard and reports the outcome, resetting after a few seconds. */
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ const resetDelay = 4000;
4
+ export default function useClipboardCopy(text) {
5
+ const [status, setStatus] = useState("idle");
6
+ const resetTimer = useRef();
7
+ useEffect(() => () => clearTimeout(resetTimer.current), []);
8
+ const copy = useCallback(async () => {
9
+ clearTimeout(resetTimer.current);
10
+ setStatus("copying");
11
+ try {
12
+ await navigator.clipboard.writeText(text);
13
+ setStatus("copied");
14
+ }
15
+ catch {
16
+ setStatus("error");
17
+ }
18
+ resetTimer.current = setTimeout(() => setStatus("idle"), resetDelay);
19
+ }, [text]);
20
+ return { status, copy };
21
+ }
@@ -0,0 +1,3 @@
1
+ /** Calls onDismiss on Escape or a pointer press outside the container while `active`. */
2
+ import { type RefObject } from "react";
3
+ export default function useDismiss(container: RefObject<HTMLElement>, active: boolean, onDismiss: (reason: "escape" | "outside") => void): void;
@@ -0,0 +1,22 @@
1
+ /** Calls onDismiss on Escape or a pointer press outside the container while `active`. */
2
+ import { useEffect } from "react";
3
+ export default function useDismiss(container, active, onDismiss) {
4
+ useEffect(() => {
5
+ if (!active)
6
+ return;
7
+ const onKeyDown = (event) => {
8
+ if (event.key === "Escape")
9
+ onDismiss("escape");
10
+ };
11
+ const onPointerDown = (event) => {
12
+ if (!container.current?.contains(event.target))
13
+ onDismiss("outside");
14
+ };
15
+ document.addEventListener("keydown", onKeyDown);
16
+ document.addEventListener("pointerdown", onPointerDown);
17
+ return () => {
18
+ document.removeEventListener("keydown", onKeyDown);
19
+ document.removeEventListener("pointerdown", onPointerDown);
20
+ };
21
+ }, [container, active, onDismiss]);
22
+ }
@@ -0,0 +1,7 @@
1
+ /** Derives header state from scroll position: scrolled, over a dark area, and the active section. */
2
+ import { type RefObject } from "react";
3
+ export default function useHeaderScroll(header: RefObject<HTMLElement>): {
4
+ scrolled: boolean;
5
+ overDark: boolean;
6
+ activeId: string;
7
+ };
@@ -0,0 +1,40 @@
1
+ /** Derives header state from scroll position: scrolled, over a dark area, and the active section. */
2
+ import { useEffect, useState } from "react";
3
+ /** Fraction of the viewport height at which a section becomes "current". */
4
+ const activationLine = 0.35;
5
+ export default function useHeaderScroll(header) {
6
+ const [scrolled, setScrolled] = useState(false);
7
+ const [overDark, setOverDark] = useState(false);
8
+ const [activeId, setActiveId] = useState("");
9
+ useEffect(() => {
10
+ const sections = [
11
+ ...document.querySelectorAll("main section[id]"),
12
+ ];
13
+ const darkAreas = [
14
+ ...document.querySelectorAll("[data-header-theme='dark']"),
15
+ ];
16
+ const update = () => {
17
+ setScrolled(window.scrollY > 20);
18
+ const headerBottom = header.current?.offsetHeight ?? 0;
19
+ setOverDark(darkAreas.some((area) => {
20
+ const bounds = area.getBoundingClientRect();
21
+ return bounds.top <= headerBottom && bounds.bottom > headerBottom;
22
+ }));
23
+ const line = window.innerHeight * activationLine;
24
+ let current = "";
25
+ sections.forEach((section) => {
26
+ if (section.getBoundingClientRect().top <= line)
27
+ current = section.id;
28
+ });
29
+ setActiveId(current);
30
+ };
31
+ update();
32
+ window.addEventListener("scroll", update, { passive: true });
33
+ window.addEventListener("resize", update);
34
+ return () => {
35
+ window.removeEventListener("scroll", update);
36
+ window.removeEventListener("resize", update);
37
+ };
38
+ }, [header]);
39
+ return { scrolled, overDark, activeId };
40
+ }
@@ -0,0 +1 @@
1
+ export default function useIdleTrigger(enabled: boolean): boolean;
@@ -0,0 +1,19 @@
1
+ /** Becomes true once the browser is idle after `enabled` turns on; used to defer heavy work. */
2
+ import { useEffect, useState } from "react";
3
+ const idleTimeout = 1200;
4
+ const fallbackDelay = 300;
5
+ export default function useIdleTrigger(enabled) {
6
+ const [triggered, setTriggered] = useState(false);
7
+ useEffect(() => {
8
+ if (!enabled || triggered)
9
+ return;
10
+ const trigger = () => setTriggered(true);
11
+ if ("requestIdleCallback" in window) {
12
+ const id = window.requestIdleCallback(trigger, { timeout: idleTimeout });
13
+ return () => window.cancelIdleCallback(id);
14
+ }
15
+ const id = setTimeout(trigger, fallbackDelay);
16
+ return () => clearTimeout(id);
17
+ }, [enabled, triggered]);
18
+ return triggered;
19
+ }
@@ -0,0 +1 @@
1
+ export default function useInitialHashScroll(): void;
@@ -0,0 +1,28 @@
1
+ /** Scrolls to the URL fragment after first render; the section does not exist yet when the browser first tries. */
2
+ import { useEffect } from "react";
3
+ const userScrollEvents = ["wheel", "touchstart", "keydown"];
4
+ export default function useInitialHashScroll() {
5
+ useEffect(() => {
6
+ let id;
7
+ try {
8
+ id = decodeURIComponent(window.location.hash.slice(1));
9
+ }
10
+ catch {
11
+ return;
12
+ }
13
+ const target = id ? document.getElementById(id) : null;
14
+ if (!target)
15
+ return;
16
+ // Scroll now, then once more after web fonts settle, unless the visitor has scrolled.
17
+ let userScrolled = false;
18
+ const markScrolled = () => (userScrolled = true);
19
+ const align = () => {
20
+ if (!userScrolled)
21
+ target.scrollIntoView({ block: "start" });
22
+ };
23
+ align();
24
+ userScrollEvents.forEach((event) => window.addEventListener(event, markScrolled, { passive: true }));
25
+ document.fonts.ready.then(align);
26
+ return () => userScrollEvents.forEach((event) => window.removeEventListener(event, markScrolled));
27
+ }, []);
28
+ }
@@ -0,0 +1,3 @@
1
+ /** Open state for the mobile menu, closing on Escape, outside pointer, or desktop resize. */
2
+ import { type RefObject } from "react";
3
+ export default function useMobileMenu(container: RefObject<HTMLElement>, toggleButton: RefObject<HTMLButtonElement>): readonly [boolean, import("react").Dispatch<import("react").SetStateAction<boolean>>];
@@ -0,0 +1,34 @@
1
+ /** Open state for the mobile menu, closing on Escape, outside pointer, or desktop resize. */
2
+ import { useEffect, useState } from "react";
3
+ const desktopQuery = "(min-width: 768px)";
4
+ export default function useMobileMenu(container, toggleButton) {
5
+ const [open, setOpen] = useState(false);
6
+ useEffect(() => {
7
+ if (!open)
8
+ return;
9
+ const closeOnEscape = (event) => {
10
+ if (event.key !== "Escape")
11
+ return;
12
+ setOpen(false);
13
+ toggleButton.current?.focus();
14
+ };
15
+ const closeOnOutsidePointer = (event) => {
16
+ if (!container.current?.contains(event.target))
17
+ setOpen(false);
18
+ };
19
+ const desktop = window.matchMedia(desktopQuery);
20
+ const closeOnDesktop = () => {
21
+ if (desktop.matches)
22
+ setOpen(false);
23
+ };
24
+ document.addEventListener("keydown", closeOnEscape);
25
+ document.addEventListener("pointerdown", closeOnOutsidePointer);
26
+ desktop.addEventListener("change", closeOnDesktop);
27
+ return () => {
28
+ document.removeEventListener("keydown", closeOnEscape);
29
+ document.removeEventListener("pointerdown", closeOnOutsidePointer);
30
+ desktop.removeEventListener("change", closeOnDesktop);
31
+ };
32
+ }, [open, container, toggleButton]);
33
+ return [open, setOpen];
34
+ }