- Fake site for Maya, a wedding photographer. White page, black type, not much else — name
- and contact in the bottom-left, a tall stack of prints on the right. On phone the stack
- goes full width; on desktop it sits on the right edge. Wedding stills from Lummi follow
- the cursor behind everything.
+ Fake site for Maya, a wedding photographer. White page, black type, not much else. Name
+ and contact sit in the bottom-left; a tall stack of prints fills the right. On phone the
+ stack goes full width; on desktop it hugs the right edge. Wedding stills from Lummi
+ follow the cursor behind everything.
The first thing you see is not the page. SplitReveal covers the screen,
loads every image, and ticks progress on the center seam. When the batch finishes, the
- halves slide apart. The layout was already there — just hidden.
+ halves slide apart. The layout was already there, just hidden.
Whichever print is on top gets corner brackets, like a viewfinder. The rest are plain
@@ -79,14 +79,14 @@ export function PhotographerPortfolioNotes() {
- SplitReveal sits next to the page as a fixed overlay — pass{" "}
+ SplitReveal sits next to the page as a fixed overlay. Pass{" "}
images, wire onComplete, done. We skipped the{" "}
.Content wrapper on purpose; the real layout mounts normally underneath.
CardStack advances the portfolio. Autoplay only starts after the preloader
clears. PrintCaption mirrors the top frame with fake shutter metadata.
- Camera and map pin icons are Lucide Animated — they only move on hover so the hero stays
+ Camera and map pin icons are Lucide Animated. They only move on hover so the hero stays
quiet.
@@ -100,8 +100,8 @@ export function PhotographerPortfolioNotes() {
- All image URLs live in LUMMI_ASSETS — six stack frames, twelve for trails,
- one avatar — cropped through a shared lummi() helper.{" "}
+ All image URLs live in LUMMI_ASSETS: six stack frames, twelve for trails,
+ one avatar. All cropped through a shared lummi() helper.{" "}
PRELOAD_IMAGES dedupes before SplitReveal sees them.
@@ -112,26 +112,26 @@ export function PhotographerPortfolioNotes() {
Print width was the annoying part. We tried JS measurement first; on desktop the caption
and stack kept drifting out of sync. Switched to a size container on the print column
- and capped width with min(100cqw, calc((100cqh - 4.5rem) * 8 / 11)) — 4:5
- frame plus a 10% peek strip (aspect-[8/1]) that fits the viewport height.
- No ResizeObserver.
+ and capped width with min(100cqw, calc((100cqh - 4.5rem) * 8 / 11)) for a
+ 4:5 frame plus a 10% peek strip (aspect-[8/1]) that fits the viewport
+ height. No ResizeObserver.
Caption had a white fill at one point. It fought the page background and made the
- metadata feel like a sticker — dropped it; caption and page share the same canvas now.
+ metadata feel like a sticker. Dropped it; caption and page share the same canvas now.
- For trails we first excluded the whole stack. Looked too clean — nothing ever crossed
- the prints. Narrowed it to the hero and caption wrappers only.
+ For trails we first excluded the whole stack. Looked too clean; nothing ever crossed the
+ prints. Narrowed it to the hero and caption wrappers only.
-
+
{LAYOUT_SNIPPET}
{PRELOADER_SNIPPET}
-
+
{TRAIL_EXCLUDE_SNIPPET}
@@ -139,7 +139,7 @@ export function PhotographerPortfolioNotes() {
- Photos from Lummi — wedding and event searches. IDs
+ Photos from Lummi, wedding and event searches. IDs
are in LUMMI_ASSETS. Fine for demos; use your own shots for anything real.
diff --git a/components/ui/command.tsx b/components/ui/command.tsx
index 2cecd910..0bc19908 100644
--- a/components/ui/command.tsx
+++ b/components/ui/command.tsx
@@ -35,16 +35,27 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
)
}
+interface CommandInputProps
+ extends React.ComponentPropsWithoutRef {
+ wrapperClassName?: string
+ hideIcon?: boolean
+}
+
const CommandInput = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-
+ CommandInputProps
+>(({ className, wrapperClassName, hideIcon, ...props }, ref) => (
+
+ {hideIcon ? null : (
+
+ )}
=6.9.0'}
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/compat-data@7.29.0':
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
engines: {node: '>=6.9.0'}
@@ -368,6 +372,10 @@ packages:
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-validator-option@7.27.1':
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
@@ -830,6 +838,10 @@ packages:
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -1804,57 +1816,57 @@ packages:
'@types/react': '>=16'
react: '>=16'
- '@next/env@16.2.4':
- resolution: {integrity: sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==}
+ '@next/env@16.2.6':
+ resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
- '@next/swc-darwin-arm64@16.2.4':
- resolution: {integrity: sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==}
+ '@next/swc-darwin-arm64@16.2.6':
+ resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.2.4':
- resolution: {integrity: sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==}
+ '@next/swc-darwin-x64@16.2.6':
+ resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.2.4':
- resolution: {integrity: sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==}
+ '@next/swc-linux-arm64-gnu@16.2.6':
+ resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.2.4':
- resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
+ '@next/swc-linux-arm64-musl@16.2.6':
+ resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.2.4':
- resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
+ '@next/swc-linux-x64-gnu@16.2.6':
+ resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.2.4':
- resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
+ '@next/swc-linux-x64-musl@16.2.6':
+ resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.2.4':
- resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
+ '@next/swc-win32-arm64-msvc@16.2.6':
+ resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.2.4':
- resolution: {integrity: sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==}
+ '@next/swc-win32-x64-msvc@16.2.6':
+ resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2693,7 +2705,7 @@ packages:
'@tsparticles/basic@3.9.1':
resolution: {integrity: sha512-ijr2dHMx0IQHqhKW3qA8tfwrR2XYbbWYdaJMQuBo2CkwBVIhZ76U+H20Y492j/NXpd1FUnt2aC0l4CEVGVGdeQ==}
- deprecated: tsParticles v4 is out, please upgrade for better performances and more features
+ deprecated: Please update to version 4.1.1
'@tsparticles/engine@3.9.1':
resolution: {integrity: sha512-DpdgAhWMZ3Eh2gyxik8FXS6BKZ8vyea+Eu5BC4epsahqTGY9V3JGGJcXC6lRJx6cPMAx1A0FaQAojPF3v6rkmQ==}
@@ -3232,8 +3244,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
- baseline-browser-mapping@2.10.21:
- resolution: {integrity: sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==}
+ baseline-browser-mapping@2.10.33:
+ resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -3322,8 +3334,8 @@ packages:
camel-case@4.1.2:
resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==}
- caniuse-lite@1.0.30001790:
- resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==}
+ caniuse-lite@1.0.30001793:
+ resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
case-sensitive-paths-webpack-plugin@2.4.0:
resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==}
@@ -4862,8 +4874,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
- next@16.2.4:
- resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==}
+ next@16.2.6:
+ resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -5516,6 +5528,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ semver@7.8.1:
+ resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
+ engines: {node: '>=10'}
+ hasBin: true
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -6189,6 +6206,12 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
'@babel/compat-data@7.29.0': {}
'@babel/core@7.29.0':
@@ -6322,6 +6345,8 @@ snapshots:
'@babel/helper-validator-identifier@7.28.5': {}
+ '@babel/helper-validator-identifier@7.29.7': {}
+
'@babel/helper-validator-option@7.27.1': {}
'@babel/helper-wrap-function@7.28.6':
@@ -6909,6 +6934,8 @@ snapshots:
'@babel/runtime@7.29.2': {}
+ '@babel/runtime@7.29.7': {}
+
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -7608,30 +7635,30 @@ snapshots:
'@types/react': 19.2.14
react: 19.2.5
- '@next/env@16.2.4': {}
+ '@next/env@16.2.6': {}
- '@next/swc-darwin-arm64@16.2.4':
+ '@next/swc-darwin-arm64@16.2.6':
optional: true
- '@next/swc-darwin-x64@16.2.4':
+ '@next/swc-darwin-x64@16.2.6':
optional: true
- '@next/swc-linux-arm64-gnu@16.2.4':
+ '@next/swc-linux-arm64-gnu@16.2.6':
optional: true
- '@next/swc-linux-arm64-musl@16.2.4':
+ '@next/swc-linux-arm64-musl@16.2.6':
optional: true
- '@next/swc-linux-x64-gnu@16.2.4':
+ '@next/swc-linux-x64-gnu@16.2.6':
optional: true
- '@next/swc-linux-x64-musl@16.2.4':
+ '@next/swc-linux-x64-musl@16.2.6':
optional: true
- '@next/swc-win32-arm64-msvc@16.2.4':
+ '@next/swc-win32-arm64-msvc@16.2.6':
optional: true
- '@next/swc-win32-x64-msvc@16.2.4':
+ '@next/swc-win32-x64-msvc@16.2.6':
optional: true
'@opentelemetry/api-logs@0.208.0':
@@ -8311,7 +8338,7 @@ snapshots:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
- '@storybook/nextjs@10.3.5(esbuild@0.17.19)(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(type-fest@2.19.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.106.2(esbuild@0.17.19))':
+ '@storybook/nextjs@10.3.5(esbuild@0.17.19)(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(type-fest@2.19.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.106.2(esbuild@0.17.19))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0)
@@ -8335,7 +8362,7 @@ snapshots:
css-loader: 6.11.0(webpack@5.106.2(esbuild@0.17.19))
image-size: 2.0.2
loader-utils: 3.3.1
- next: 16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
node-polyfill-webpack-plugin: 2.0.1(webpack@5.106.2(esbuild@0.17.19))
postcss: 8.5.15
postcss-loader: 8.2.1(postcss@8.5.15)(typescript@5.9.3)(webpack@5.106.2(esbuild@0.17.19))
@@ -8508,8 +8535,8 @@ snapshots:
'@testing-library/dom@10.4.1':
dependencies:
- '@babel/code-frame': 7.29.0
- '@babel/runtime': 7.29.2
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
@@ -9196,7 +9223,7 @@ snapshots:
base64-js@1.5.1: {}
- baseline-browser-mapping@2.10.21: {}
+ baseline-browser-mapping@2.10.33: {}
big.js@5.2.2: {}
@@ -9265,8 +9292,8 @@ snapshots:
browserslist@4.28.2:
dependencies:
- baseline-browser-mapping: 2.10.21
- caniuse-lite: 1.0.30001790
+ baseline-browser-mapping: 2.10.33
+ caniuse-lite: 1.0.30001793
electron-to-chromium: 1.5.344
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
@@ -9310,7 +9337,7 @@ snapshots:
pascal-case: 3.1.2
tslib: 2.8.1
- caniuse-lite@1.0.30001790: {}
+ caniuse-lite@1.0.30001793: {}
case-sensitive-paths-webpack-plugin@2.4.0: {}
@@ -11201,25 +11228,25 @@ snapshots:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
- next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
+ next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
- '@next/env': 16.2.4
+ '@next/env': 16.2.6
'@swc/helpers': 0.5.15
- baseline-browser-mapping: 2.10.21
- caniuse-lite: 1.0.30001790
+ baseline-browser-mapping: 2.10.33
+ caniuse-lite: 1.0.30001793
postcss: 8.4.31
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.2.4
- '@next/swc-darwin-x64': 16.2.4
- '@next/swc-linux-arm64-gnu': 16.2.4
- '@next/swc-linux-arm64-musl': 16.2.4
- '@next/swc-linux-x64-gnu': 16.2.4
- '@next/swc-linux-x64-musl': 16.2.4
- '@next/swc-win32-arm64-msvc': 16.2.4
- '@next/swc-win32-x64-msvc': 16.2.4
+ '@next/swc-darwin-arm64': 16.2.6
+ '@next/swc-darwin-x64': 16.2.6
+ '@next/swc-linux-arm64-gnu': 16.2.6
+ '@next/swc-linux-arm64-musl': 16.2.6
+ '@next/swc-linux-x64-gnu': 16.2.6
+ '@next/swc-linux-x64-musl': 16.2.6
+ '@next/swc-win32-arm64-msvc': 16.2.6
+ '@next/swc-win32-x64-msvc': 16.2.6
'@opentelemetry/api': 1.9.1
sharp: 0.34.5
transitivePeerDependencies:
@@ -11272,12 +11299,12 @@ snapshots:
dependencies:
boolbase: 1.0.0
- nuqs@2.8.9(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5):
+ nuqs@2.8.9(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5):
dependencies:
'@standard-schema/spec': 1.0.0
react: 19.2.5
optionalDependencies:
- next: 16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
object-inspect@1.13.4: {}
@@ -11985,6 +12012,8 @@ snapshots:
semver@7.7.4: {}
+ semver@7.8.1: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -12033,7 +12062,7 @@ snapshots:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
- semver: 7.7.4
+ semver: 7.8.1
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
diff --git a/scripts/validate-registry-install.js b/scripts/validate-registry-install.js
new file mode 100644
index 00000000..115a9aae
--- /dev/null
+++ b/scripts/validate-registry-install.js
@@ -0,0 +1,355 @@
+import { execSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const REGISTRY_DIR = path.join(ROOT, "public", "r");
+const SITE_URL = (process.env.NEXT_PUBLIC_APP_URL || "https://animata.design").replace(/\/$/, "");
+
+/** Representative installs — one dry-run per bundle shape. */
+const INSTALL_FIXTURES = [
+ {
+ id: "simple",
+ label: "single file, no npm deps",
+ item: "preloader/split-reveal.json",
+ expectFiles: ["components/animata/preloader/split-reveal.tsx"],
+ expectDeps: [],
+ },
+ {
+ id: "doc-bundled-shapes",
+ label: "mdx bundled file refs + shape imports",
+ item: "card/card-stack.json",
+ expectFiles: [
+ "components/animata/card/card-stack.tsx",
+ "components/shapes/card-stack-mask-defs.tsx",
+ ],
+ expectDeps: ["lucide-react", "motion"],
+ },
+ {
+ id: "co-located-shared",
+ label: "co-located helper rewritten to ./shared",
+ item: "tabs/fluid-tabs.json",
+ expectFiles: ["components/animata/tabs/fluid-tabs.tsx", "components/animata/tabs/shared.ts"],
+ expectDeps: ["lucide-react", "motion"],
+ },
+ {
+ id: "bundled-hook",
+ label: "bundled hook from @/hooks import",
+ item: "image/trailing-image.json",
+ expectFiles: ["components/animata/image/trailing-image.tsx", "hooks/use-mouse-position.ts"],
+ expectDeps: ["motion"],
+ },
+ {
+ id: "registry-dependencies",
+ label: "registryDependencies resolve to sibling items",
+ item: "card/card-spread.json",
+ localizeDeps: true,
+ expectFiles: [
+ "components/animata/card/card-spread.tsx",
+ "components/animata/widget/notes.tsx",
+ "components/animata/widget/shopping-list.tsx",
+ ],
+ expectDeps: [],
+ },
+];
+
+let failed = false;
+
+function error(message) {
+ console.error(`[registry-install] ${message}`);
+ failed = true;
+}
+
+function info(message) {
+ console.log(`[registry-install] ${message}`);
+}
+
+function walkRegistryJson(dir, acc = []) {
+ if (!fs.existsSync(dir)) return acc;
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walkRegistryJson(full, acc);
+ } else if (entry.isFile() && entry.name.endsWith(".json")) {
+ acc.push(full);
+ }
+ }
+ return acc;
+}
+
+function registryUrlToLocalPath(url) {
+ const prefix = `${SITE_URL}/r/`;
+ if (!url.startsWith(prefix)) return null;
+ return path.join(REGISTRY_DIR, url.slice(prefix.length));
+}
+
+function localizeRegistryItem(itemPath) {
+ const raw = JSON.parse(fs.readFileSync(itemPath, "utf8"));
+ if (!raw.registryDependencies?.length) return itemPath;
+
+ const localized = structuredClone(raw);
+ localized.registryDependencies = raw.registryDependencies.map((dep) => {
+ const local = registryUrlToLocalPath(dep);
+ if (!local) return dep;
+ if (!fs.existsSync(local)) {
+ error(
+ `missing local registry dependency for ${path.relative(REGISTRY_DIR, itemPath)}: ${dep}`,
+ );
+ return dep;
+ }
+ return local;
+ });
+
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "animata-registry-"));
+ const outPath = path.join(tmpDir, path.basename(itemPath));
+ fs.writeFileSync(outPath, `${JSON.stringify(localized, null, 2)}\n`);
+ return outPath;
+}
+
+function validateRegistryJsonStatic(jsonPath) {
+ const rel = path.relative(REGISTRY_DIR, jsonPath);
+ let item;
+
+ try {
+ item = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
+ } catch {
+ error(`${rel}: invalid JSON`);
+ return;
+ }
+
+ if (!item.name || !item.type) {
+ error(`${rel}: missing name or type`);
+ }
+
+ if (!Array.isArray(item.files) || item.files.length === 0) {
+ error(`${rel}: files array is empty`);
+ return;
+ }
+
+ const paths = new Set();
+ for (const file of item.files) {
+ if (
+ !file.path ||
+ !file.type ||
+ typeof file.content !== "string" ||
+ file.content.trim() === ""
+ ) {
+ error(`${rel}: file entry missing path, type, or content`);
+ continue;
+ }
+ if (paths.has(file.path)) {
+ error(`${rel}: duplicate file path ${file.path}`);
+ }
+ paths.add(file.path);
+ }
+
+ for (const dep of item.registryDependencies ?? []) {
+ const local = registryUrlToLocalPath(dep);
+ if (local && !fs.existsSync(local)) {
+ error(`${rel}: registryDependency not found locally — ${dep}`);
+ }
+ }
+}
+
+function createShadcnWorkspace() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "animata-shadcn-"));
+
+ fs.writeFileSync(
+ path.join(dir, "package.json"),
+ `${JSON.stringify({ name: "animata-registry-install-test", private: true, type: "module", version: "0.0.0" }, null, 2)}\n`,
+ );
+
+ fs.writeFileSync(
+ path.join(dir, "tsconfig.json"),
+ `${JSON.stringify(
+ {
+ compilerOptions: {
+ target: "ES2017",
+ lib: ["dom", "dom.iterable", "esnext"],
+ allowJs: true,
+ skipLibCheck: true,
+ strict: true,
+ noEmit: true,
+ esModuleInterop: true,
+ module: "esnext",
+ moduleResolution: "bundler",
+ jsx: "preserve",
+ paths: { "@/*": ["./*"] },
+ },
+ include: ["**/*.ts", "**/*.tsx"],
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+
+ fs.mkdirSync(path.join(dir, "app"), { recursive: true });
+ fs.mkdirSync(path.join(dir, "lib"), { recursive: true });
+ fs.writeFileSync(path.join(dir, "app", "globals.css"), '@import "tailwindcss";\n');
+ fs.writeFileSync(
+ path.join(dir, "lib", "utils.ts"),
+ 'export function cn(...inputs: Array) {\n return inputs.filter(Boolean).join(" ");\n}\n',
+ );
+
+ fs.writeFileSync(
+ path.join(dir, "components.json"),
+ `${JSON.stringify(
+ {
+ $schema: "https://ui.shadcn.com/schema.json",
+ style: "new-york",
+ rsc: true,
+ tsx: true,
+ tailwind: {
+ css: "app/globals.css",
+ baseColor: "stone",
+ cssVariables: true,
+ prefix: "",
+ },
+ aliases: {
+ components: "@/components",
+ utils: "@/lib/utils",
+ },
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+
+ return dir;
+}
+
+function parseDryRunOutput(output) {
+ const files = [];
+ const deps = [];
+ let inDeps = false;
+
+ for (const line of output.split("\n")) {
+ const fileMatch = line.match(/^│ \+ (.+?) {2}create$/);
+ if (fileMatch) {
+ files.push(fileMatch[1].trim());
+ continue;
+ }
+
+ if (line.includes("├ Dependencies")) {
+ inDeps = true;
+ continue;
+ }
+
+ if (inDeps) {
+ const depMatch = line.match(/^│ \+ (.+)$/);
+ if (depMatch) {
+ deps.push(depMatch[1].trim().split("@")[0]);
+ continue;
+ }
+ if (line.includes("├ Files") || line.includes("│ ") || line.startsWith("└")) {
+ inDeps = false;
+ }
+ }
+ }
+
+ return { files, deps };
+}
+
+function runShadcnDryRun(workspaceDir, registryItemPath) {
+ const command = `npx shadcn@latest add -y --dry-run ${JSON.stringify(registryItemPath)}`;
+
+ try {
+ const output = execSync(command, {
+ cwd: workspaceDir,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ timeout: 120_000,
+ env: { ...process.env, CI: "true" },
+ });
+ return { ok: true, output };
+ } catch (err) {
+ const stdout = err.stdout?.toString?.() ?? "";
+ const stderr = err.stderr?.toString?.() ?? "";
+ return { ok: false, output: `${stdout}\n${stderr}`.trim() };
+ }
+}
+
+function assertFixtureInstall(fixture, workspaceDir) {
+ const itemPath = path.join(REGISTRY_DIR, fixture.item);
+ if (!fs.existsSync(itemPath)) {
+ error(`fixture ${fixture.id}: missing ${fixture.item}`);
+ return;
+ }
+
+ const installPath = fixture.localizeDeps ? localizeRegistryItem(itemPath) : itemPath;
+ const result = runShadcnDryRun(workspaceDir, installPath);
+
+ if (!result.ok) {
+ error(`fixture ${fixture.id} (${fixture.label}): shadcn dry-run failed\n${result.output}`);
+ return;
+ }
+
+ const { files, deps } = parseDryRunOutput(result.output);
+
+ for (const expected of fixture.expectFiles) {
+ if (!files.includes(expected)) {
+ error(
+ `fixture ${fixture.id}: expected file "${expected}" in dry-run output. Got: ${files.join(", ") || "(none)"}`,
+ );
+ }
+ }
+
+ for (const expectedDep of fixture.expectDeps) {
+ if (!deps.includes(expectedDep)) {
+ error(
+ `fixture ${fixture.id}: expected npm dep "${expectedDep}". Got: ${deps.join(", ") || "(none)"}`,
+ );
+ }
+ }
+
+ info(`fixture ${fixture.id} OK — ${files.length} file(s), ${deps.length} npm dep(s)`);
+}
+
+function main() {
+ if (process.argv.includes("--build")) {
+ execSync("node ./scripts/build-registry.js", { cwd: ROOT, stdio: "inherit" });
+ }
+
+ if (!fs.existsSync(REGISTRY_DIR)) {
+ error(`registry output missing at ${REGISTRY_DIR} — run node scripts/build-registry.js first`);
+ process.exit(1);
+ }
+
+ const registryFiles = walkRegistryJson(REGISTRY_DIR);
+ if (registryFiles.length === 0) {
+ error("no registry JSON files found");
+ process.exit(1);
+ }
+
+ info(`static validation — ${registryFiles.length} item(s)`);
+ for (const jsonPath of registryFiles) {
+ validateRegistryJsonStatic(jsonPath);
+ }
+
+ if (failed) {
+ process.exit(1);
+ }
+
+ info(`shadcn dry-run — ${INSTALL_FIXTURES.length} fixture(s)`);
+ const workspaceDir = createShadcnWorkspace();
+
+ try {
+ for (const fixture of INSTALL_FIXTURES) {
+ assertFixtureInstall(fixture, workspaceDir);
+ }
+ } finally {
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
+ }
+
+ if (failed) {
+ process.exit(1);
+ }
+
+ info(
+ `OK — ${registryFiles.length} registry item(s), ${INSTALL_FIXTURES.length} install fixture(s)`,
+ );
+}
+
+main();