;
}) {
return (
-
-
- Browse Particles
-
- {description}
-
-
-
-
-
-
-
-
-
+ <>
+
+
+
+ Build more with Particles.
+
+
+ {description}
+
+
+
+
+
+
+
+
+
+
+ >
);
}
diff --git a/apps/ui/app/particles/search-container.tsx b/apps/ui/app/particles/search-container.tsx
index ce9655fea5..f2c3e3f020 100644
--- a/apps/ui/app/particles/search-container.tsx
+++ b/apps/ui/app/particles/search-container.tsx
@@ -50,12 +50,10 @@ export default function SearchContainer() {
);
return (
-
-
-
+
);
}
diff --git a/apps/ui/app/particles/search-field.tsx b/apps/ui/app/particles/search-field.tsx
index eaa3e06730..0a0967d357 100644
--- a/apps/ui/app/particles/search-field.tsx
+++ b/apps/ui/app/particles/search-field.tsx
@@ -3,7 +3,7 @@
import { LabelIcon, Search01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import * as React from "react";
-import { useEffect, useMemo, useState } from "react";
+import { useContext, useEffect, useMemo, useState } from "react";
import { Index } from "@/registry/__index__";
import {
Combobox,
@@ -11,12 +11,13 @@ import {
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
+ ComboboxContext,
ComboboxEmpty,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxItem,
ComboboxList,
- ComboboxPopup,
+ ComboboxPrimitive,
ComboboxSeparator,
ComboboxValue,
} from "@/registry/default/ui/combobox";
@@ -132,7 +133,7 @@ export default function SearchField({
};
return (
-
+
-
+
No filters found.
{(group: (typeof groupedItems)[number]) => (
@@ -213,8 +214,40 @@ export default function SearchField({
)}
-
+
);
}
+
+// Keep this page’s search below the field without changing the shared popup.
+function SearchPopup({ children }: { children: React.ReactNode }) {
+ const { chipsRef } = useContext(ComboboxContext);
+
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/apps/ui/components/blueprint-dimensions.tsx b/apps/ui/components/blueprint-dimensions.tsx
new file mode 100644
index 0000000000..dfc082b4b6
--- /dev/null
+++ b/apps/ui/components/blueprint-dimensions.tsx
@@ -0,0 +1,29 @@
+export function HorizontalDimension({
+ x1,
+ x2,
+ y,
+ label,
+}: {
+ x1: number;
+ x2: number;
+ y: number;
+ label: string;
+}) {
+ return (
+ <>
+
+
+ {label}
+
+ >
+ );
+}
diff --git a/apps/ui/components/blueprint-drawing.tsx b/apps/ui/components/blueprint-drawing.tsx
new file mode 100644
index 0000000000..1b1e0aebdf
--- /dev/null
+++ b/apps/ui/components/blueprint-drawing.tsx
@@ -0,0 +1,17 @@
+import type { ReactNode } from "react";
+
+export function BlueprintDrawing({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/ui/components/blueprint-motion.module.css b/apps/ui/components/blueprint-motion.module.css
new file mode 100644
index 0000000000..3a33eeaf27
--- /dev/null
+++ b/apps/ui/components/blueprint-motion.module.css
@@ -0,0 +1,13 @@
+.animation {
+ display: none;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .drawing[data-live] .animation {
+ display: block;
+ }
+
+ .drawing[data-live] .original {
+ visibility: hidden;
+ }
+}
diff --git a/apps/ui/components/blueprint-motion.tsx b/apps/ui/components/blueprint-motion.tsx
new file mode 100644
index 0000000000..0c3005b36f
--- /dev/null
+++ b/apps/ui/components/blueprint-motion.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { useMediaQuery } from "@/registry/default/hooks/use-media-query";
+import styles from "./blueprint-motion.module.css";
+
+export function BlueprintMotion({
+ children,
+ renderAnimation,
+}: {
+ children: ReactNode;
+ renderAnimation: (paused: boolean) => ReactNode;
+}) {
+ const drawing = useRef
(null);
+ const animation = useRef(null);
+ const [hasEntered, setHasEntered] = useState(false);
+ const [inView, setInView] = useState(false);
+ const [pageVisible, setPageVisible] = useState(true);
+ const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
+ const live = hasEntered && !reducedMotion;
+ const stopped = !inView || !pageVisible || !live;
+
+ useEffect(() => {
+ if (!drawing.current) return;
+ const observer = new IntersectionObserver(([entry]) => {
+ if (!entry) return;
+ setInView(entry.isIntersecting);
+ if (entry.isIntersecting) setHasEntered(true);
+ });
+ observer.observe(drawing.current);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const update = () => setPageVisible(!document.hidden);
+ update();
+ document.addEventListener("visibilitychange", update);
+ return () => document.removeEventListener("visibilitychange", update);
+ }, []);
+
+ useEffect(() => {
+ if (!live) return;
+ const svg = animation.current?.querySelector("svg");
+ if (!svg) return;
+ if (stopped) svg.pauseAnimations();
+ else svg.unpauseAnimations();
+ }, [stopped, live]);
+
+ return (
+
+
{children}
+ {live && (
+
+ {renderAnimation(stopped)}
+
+ )}
+
+ );
+}
diff --git a/apps/ui/components/not-found-blueprint.tsx b/apps/ui/components/not-found-blueprint.tsx
new file mode 100644
index 0000000000..a094b8d6fc
--- /dev/null
+++ b/apps/ui/components/not-found-blueprint.tsx
@@ -0,0 +1,95 @@
+import { HorizontalDimension } from "./blueprint-dimensions";
+import { BlueprintDrawing } from "./blueprint-drawing";
+
+function RequestRoute() {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function AbsentPage({ dash = "3 5" }: { dash?: string }) {
+ return (
+
+
+
+
+
+ );
+}
+
+// The marker follows the true R32
+// centerline inside the 16-unit route; its 3.5-unit radius stays clear of the
+// closed end at x272. No part of the request crosses the 32-unit address gap.
+export function NotFoundBlueprint({
+ animated = false,
+}: {
+ animated?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {animated && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Request
+
+
+ 404
+
+
+
+
+ R40
+
+
+ No page at this address
+
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/components/not-found-motion.tsx b/apps/ui/components/not-found-motion.tsx
new file mode 100644
index 0000000000..c4ee893af7
--- /dev/null
+++ b/apps/ui/components/not-found-motion.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { BlueprintMotion } from "./blueprint-motion";
+import { NotFoundBlueprint } from "./not-found-blueprint";
+
+export function NotFoundMotion({ children }: { children: ReactNode }) {
+ return (
+ }>
+ {children}
+
+ );
+}
diff --git a/apps/ui/components/particles-assembly-content.tsx b/apps/ui/components/particles-assembly-content.tsx
new file mode 100644
index 0000000000..fc1232553c
--- /dev/null
+++ b/apps/ui/components/particles-assembly-content.tsx
@@ -0,0 +1,109 @@
+import type { ReactNode } from "react";
+
+export type AssemblyScene = "workspace" | "booking" | "activity";
+
+export function AssemblyContent({
+ scene,
+ part,
+ trailingMotion,
+ thumbMotion,
+}: {
+ scene: AssemblyScene;
+ part: 0 | 1 | 2;
+ trailingMotion?: ReactNode;
+ thumbMotion?: ReactNode;
+}) {
+ const label = {
+ workspace: ["Studio", "Public", "Open"],
+ booking: ["Wed 28", "10:30", "Meet"],
+ activity: ["", "", "Ready"],
+ }[scene][part];
+ const labelX = part === 0 || (scene === "booking" && part === 1) ? 40 : 14;
+
+ return (
+
+
+ {scene === "workspace" && part === 0 && (
+
+ )}
+ {scene === "workspace" && part === 1 && (
+
+ {trailingMotion}
+
+
+ {thumbMotion}
+
+
+ )}
+ {scene === "booking" && part === 0 && (
+
+ )}
+ {scene === "booking" && part === 1 && }
+ {scene === "booking" && part === 2 && (
+ <>
+
+
+ >
+ )}
+ {scene === "activity" && part === 0 && (
+
+ )}
+
+
+ {scene === "workspace" && part === 0 && }
+ {scene === "workspace" && part === 2 && (
+
+ {trailingMotion}
+
+
+ )}
+ {scene === "booking" && part === 0 && (
+
+ )}
+ {scene === "booking" && part === 1 && }
+ {scene === "activity" && part === 0 && (
+ <>
+
+
+ >
+ )}
+ {scene === "activity" && part === 2 && }
+
+ {scene === "activity" ? (
+
+ {part === 1 && (
+ <>
+
+ New reply
+
+
+ Maya · just now
+
+ >
+ )}
+ {part === 2 && (
+
+ Ready
+
+ )}
+
+ ) : (
+
+ {label}
+
+ )}
+
+ );
+}
diff --git a/apps/ui/components/particles-assembly-drawing.tsx b/apps/ui/components/particles-assembly-drawing.tsx
new file mode 100644
index 0000000000..53cfbeacb6
--- /dev/null
+++ b/apps/ui/components/particles-assembly-drawing.tsx
@@ -0,0 +1,513 @@
+import { BlueprintDrawing } from "./blueprint-drawing";
+import {
+ AssemblyContent,
+ type AssemblyScene,
+} from "./particles-assembly-content";
+import {
+ type AssemblyAxis,
+ type AssemblyPart,
+ type Corner,
+ type Edge,
+ jointDuration,
+ jointNeighbor,
+ jointTime,
+ type ModuleGeometry,
+ outlineAt,
+ outlineTimes,
+ travel,
+} from "./particles-assembly-geometry";
+import { secondaryLine } from "./primitives-morph-style";
+
+export const particlesSequenceDuration = 16.2;
+const chapterDuration = particlesSequenceDuration / 3;
+const parts = [0, 1, 2] as const;
+type Part = AssemblyPart;
+type Frame = readonly [number, string | number];
+const edges: Edge[] = ["top", "right", "bottom", "left"];
+const corners: Corner[] = ["topLeft", "topRight", "bottomRight", "bottomLeft"];
+type Assembly = {
+ scene: AssemblyScene;
+ axis: AssemblyAxis;
+ unit: number;
+ labels: readonly [string, string, string];
+ modules: readonly [ModuleGeometry, ModuleGeometry, ModuleGeometry];
+ frame: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ radius: number;
+ };
+};
+const assemblies: Assembly[] = [
+ {
+ scene: "workspace",
+ axis: "vertical",
+ unit: 40,
+ labels: ["Identity", "Visibility", "Action"],
+ modules: [
+ { width: 120, targetWidth: 240, height: 40, radius: 8, x: 160, y: 224 },
+ { width: 120, targetWidth: 240, height: 40, radius: 8, x: 160, y: 264 },
+ { width: 120, targetWidth: 240, height: 40, radius: 8, x: 160, y: 304 },
+ ],
+ frame: { x: 160, y: 224, width: 240, height: 120, radius: 8 },
+ },
+ {
+ scene: "booking",
+ axis: "horizontal",
+ unit: 40,
+ labels: ["Date", "Time", "Meeting"],
+ modules: [
+ { width: 120, height: 40, radius: 8, x: 100, y: 264 },
+ { width: 120, height: 40, radius: 8, x: 220, y: 264 },
+ { width: 120, height: 40, radius: 8, x: 340, y: 264 },
+ ],
+ frame: { x: 100, y: 264, width: 360, height: 40, radius: 8 },
+ },
+ {
+ scene: "activity",
+ axis: "horizontal",
+ unit: 24,
+ labels: ["Avatar", "Message", "Status"],
+ modules: [
+ { width: 48, height: 48, radius: 8, x: 148, y: 260 },
+ { width: 144, height: 48, radius: 8, x: 196, y: 260 },
+ { width: 72, height: 32, targetHeight: 48, radius: 16, x: 340, y: 260 },
+ ],
+ frame: { x: 148, y: 260, width: 264, height: 48, radius: 8 },
+ },
+];
+
+function Motion({
+ index,
+ attributeName,
+ frames,
+ mode = "spline",
+}: {
+ index: number;
+ attributeName: string;
+ frames: readonly Frame[];
+ mode?: "spline" | "linear" | "discrete";
+}) {
+ const timeline = frames.map(
+ ([time, value]) => [time + index * chapterDuration, value] as const,
+ );
+ const first = timeline[0];
+ const last = timeline[timeline.length - 1];
+ if (!first || !last) return null;
+ if (first[0] > 0) timeline.unshift([0, first[1]]);
+ if (last[0] < particlesSequenceDuration)
+ timeline.push([particlesSequenceDuration, last[1]]);
+ const timing = {
+ attributeName,
+ begin: "0s",
+ dur: `${particlesSequenceDuration}s`,
+ repeatCount: "indefinite",
+ calcMode: mode,
+ keyTimes: timeline
+ .map(([time]) => time / particlesSequenceDuration)
+ .join(";"),
+ keySplines:
+ mode === "spline"
+ ? timeline
+ .slice(1)
+ .map(() => "0.4 0 0.2 1")
+ .join(";")
+ : undefined,
+ values: timeline.map(([, value]) => value).join(";"),
+ };
+ return attributeName === "transform" ? (
+
+ ) : (
+
+ );
+}
+
+function FragmentOutline({
+ assembly,
+ index,
+ part,
+}: {
+ assembly: Assembly;
+ index: number;
+ part: Part;
+}) {
+ const times = outlineTimes(assembly.modules, assembly.axis, part);
+ const geometry = times.map((time) =>
+ outlineAt(assembly.modules, assembly.axis, part, time),
+ );
+ const initial = outlineAt(assembly.modules, assembly.axis, part, 0);
+ return (
+
+ {edges.map((edge) => {
+ const neighbor = jointNeighbor(assembly.axis, part, edge);
+ // Only the facing strokes merge. The exterior corner arcs keep their weight.
+ const ownsDivider = edge === "bottom" || edge === "right";
+ const contact =
+ neighbor === undefined ? undefined : jointTime(part, neighbor);
+ return (
+
+ [
+ time,
+ geometry[i]?.[edge] ?? initial[edge],
+ ])}
+ />
+ {contact !== undefined && (
+ <>
+
+
+ >
+ )}
+
+ );
+ })}
+ {corners.map((corner) => (
+
+ [
+ time,
+ geometry[i]?.[corner] ?? initial[corner],
+ ])}
+ />
+
+ ))}
+
+ );
+}
+
+function Fragment({
+ assembly,
+ index,
+ part,
+}: {
+ assembly: Assembly;
+ index: number;
+ part: Part;
+}) {
+ const { scene } = assembly;
+ const module = assembly.modules[part];
+ const sourceX = 140 + part * 140 - module.width / 2;
+ const sourceY = 124 - module.height / 2;
+ const { start, end } = travel(part);
+ const grows = module.targetWidth !== undefined;
+ return (
+ <>
+
+
+
+
+
+
+
+ {module.targetHeight !== undefined && (
+
+ )}
+
+ ) : undefined
+ }
+ thumbMotion={
+ scene === "workspace" ? (
+
+ ) : undefined
+ }
+ />
+
+
+ >
+ );
+}
+
+function Receiver({ assembly, index }: { assembly: Assembly; index: number }) {
+ return (
+
+ {parts.map((part) => {
+ const module = assembly.modules[part];
+ const final = outlineAt(
+ assembly.modules,
+ assembly.axis,
+ part,
+ chapterDuration,
+ );
+ const { end } = travel(part);
+ return (
+
+
+ {edges
+ .filter(
+ (edge) =>
+ jointNeighbor(assembly.axis, part, edge) === undefined,
+ )
+ .map((edge) => (
+
+ ))}
+ {corners.map((corner) => (
+
+ ))}
+
+ );
+ })}
+
+ );
+}
+
+function widthDimension(left: number, right: number, y: number) {
+ const center = (left + right) / 2;
+ return `M${left} ${y - 8}V${y + 8} M${right} ${y - 8}V${y + 8} M${left} ${y}H${center - 16} M${center + 16} ${y}H${right} M${left - 4} ${y + 4}l8-8 M${right - 4} ${y + 4}l8-8`;
+}
+
+function Measurements({
+ assembly,
+ index,
+}: {
+ assembly: Assembly;
+ index: number;
+}) {
+ const { frame } = assembly;
+ const right = frame.x + frame.width;
+ const bottom = frame.y + frame.height;
+ const cx = frame.x + frame.width / 2;
+ const cy = frame.y + frame.height / 2;
+ const dy = bottom + 32;
+ return (
+
+
+
+
+
+
+ {frame.height / assembly.unit}u
+
+
+ {frame.width / assembly.unit}u
+
+
+ A + B + C
+
+
+
+ );
+}
+
+function Construction({ assembly }: { assembly: Assembly }) {
+ const { frame, scene, modules } = assembly;
+ const left = scene === "workspace" ? 140 : frame.x - 20;
+ const right = scene === "workspace" ? 420 : frame.x + frame.width + 20;
+ const top = frame.y - 20;
+ const bottom = frame.y + frame.height + 20;
+ return (
+ <>
+
+
+ {modules.flatMap((module, part) => {
+ const x = 140 + part * 140 - module.width / 2;
+ return [x, x + module.width / 2, x + module.width].map((column) => (
+
+ ));
+ })}
+
+ {/* Each field is fixed; all guides stop outside the assembled content. */}
+
+
+ {Array.from(
+ { length: Math.floor(frame.width / assembly.unit) + 1 },
+ (_, i) => frame.x + i * assembly.unit,
+ ).map((x) => (
+
+ ))}
+
+
+
+ {[140, 280, 420].map((x) => (
+
+
+
+
+ ))}
+
+
+
+ {assembly.labels.map((label, part) => (
+
+ {String.fromCharCode(65 + part)} / {label}
+
+ ))}
+
+ >
+ );
+}
+
+export function ParticlesAssemblyDrawing() {
+ return (
+
+ {assemblies.map((assembly, index) => (
+
+
+
+
+ {parts.map((part) => (
+
+ ))}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/ui/components/particles-assembly-geometry.ts b/apps/ui/components/particles-assembly-geometry.ts
new file mode 100644
index 0000000000..51a08f4f12
--- /dev/null
+++ b/apps/ui/components/particles-assembly-geometry.ts
@@ -0,0 +1,178 @@
+export type AssemblyPart = 0 | 1 | 2;
+export type Edge = "top" | "right" | "bottom" | "left";
+export type Corner = "topLeft" | "topRight" | "bottomRight" | "bottomLeft";
+export type AssemblyAxis = "vertical" | "horizontal";
+export type ModuleGeometry = {
+ width: number;
+ height: number;
+ radius: number;
+ x: number;
+ y: number;
+ targetWidth?: number;
+ targetHeight?: number;
+};
+
+const circleControl = 0.5522847498;
+const samplingRate = 60;
+export const jointDuration = 0.22;
+
+export function travel(part: AssemblyPart) {
+ const start = 0.45 + (2 - part) * 0.32;
+ return { start, end: start + 0.9 };
+}
+
+export function easing(progress: number) {
+ if (progress <= 0) return 0;
+ if (progress >= 1) return 1;
+
+ let low = 0;
+ let high = 1;
+ for (let iteration = 0; iteration < 32; iteration++) {
+ const position = (low + high) / 2;
+ const inverse = 1 - position;
+ const x =
+ 3 * inverse * inverse * position * 0.4 +
+ 3 * inverse * position * position * 0.2 +
+ position * position * position;
+ if (x < progress) low = position;
+ else high = position;
+ }
+
+ const position = (low + high) / 2;
+ return 3 * (1 - position) * position * position + position ** 3;
+}
+
+export function jointNeighbor(
+ axis: AssemblyAxis,
+ part: AssemblyPart,
+ edge: Edge,
+): AssemblyPart | undefined {
+ const previous = axis === "vertical" ? "top" : "left";
+ const next = axis === "vertical" ? "bottom" : "right";
+ if (edge === previous && part > 0) return (part - 1) as AssemblyPart;
+ if (edge === next && part < 2) return (part + 1) as AssemblyPart;
+ return undefined;
+}
+
+export function jointTime(part: AssemblyPart, neighbor: AssemblyPart) {
+ return Math.max(travel(part).end, travel(neighbor).end);
+}
+
+function flightProgress(part: AssemblyPart, time: number) {
+ const { start, end } = travel(part);
+ return easing((time - start) / (end - start));
+}
+
+export function sizeAt(
+ module: ModuleGeometry,
+ part: AssemblyPart,
+ time: number,
+) {
+ const progress = flightProgress(part, time);
+ return {
+ width:
+ module.width +
+ ((module.targetWidth ?? module.width) - module.width) * progress,
+ height:
+ module.height +
+ ((module.targetHeight ?? module.height) - module.height) * progress,
+ radius: module.radius + (8 - module.radius) * progress,
+ progress,
+ };
+}
+
+export function positionAt(
+ modules: readonly ModuleGeometry[],
+ _axis: AssemblyAxis,
+ part: AssemblyPart,
+ time: number,
+) {
+ const module = modules[part];
+ if (!module) throw new Error(`Missing assembly module ${part}`);
+ const progress = flightProgress(part, time);
+ const sourceX = 140 + part * 140 - module.width / 2;
+ const sourceY = 124 - module.height / 2;
+ return {
+ x: sourceX + (module.x - sourceX) * progress,
+ y: sourceY + (module.y - sourceY) * progress,
+ };
+}
+
+function coordinate(value: number) {
+ return Number(value.toFixed(4));
+}
+
+export function outlineAt(
+ modules: readonly ModuleGeometry[],
+ axis: AssemblyAxis,
+ part: AssemblyPart,
+ time: number,
+): Record {
+ const module = modules[part];
+ if (!module) throw new Error(`Missing assembly module ${part}`);
+ const { width, height, radius } = sizeAt(module, part, time);
+
+ function corner(edge: Edge) {
+ const neighbor = jointNeighbor(axis, part, edge);
+ if (neighbor === undefined) return radius;
+ const contact = jointTime(part, neighbor);
+ return radius * (1 - easing((time - contact) / jointDuration));
+ }
+
+ const topLeft = corner(axis === "vertical" ? "top" : "left");
+ const topRight = corner(axis === "vertical" ? "top" : "right");
+ const bottomRight = corner(axis === "vertical" ? "bottom" : "right");
+ const bottomLeft = corner(axis === "vertical" ? "bottom" : "left");
+ const point = (x: number, y: number) => `${coordinate(x)} ${coordinate(y)}`;
+
+ // Separate arcs stay primary while only the straight mating edges become dividers.
+ return {
+ top: `M${point(topLeft, 0)} L${point(width - topRight, 0)}`,
+ right: `M${point(width, topRight)} L${point(width, height - bottomRight)}`,
+ bottom: `M${point(width - bottomRight, height)} L${point(bottomLeft, height)}`,
+ left: `M${point(0, height - bottomLeft)} L${point(0, topLeft)}`,
+ topLeft: `M${point(0, topLeft)} C${point(0, topLeft * (1 - circleControl))} ${point(topLeft * (1 - circleControl), 0)} ${point(topLeft, 0)}`,
+ topRight: `M${point(width - topRight, 0)} C${point(width - topRight * (1 - circleControl), 0)} ${point(width, topRight * (1 - circleControl))} ${point(width, topRight)}`,
+ bottomRight: `M${point(width, height - bottomRight)} C${point(width, height - bottomRight * (1 - circleControl))} ${point(width - bottomRight * (1 - circleControl), height)} ${point(width - bottomRight, height)}`,
+ bottomLeft: `M${point(bottomLeft, height)} C${point(bottomLeft * (1 - circleControl), height)} ${point(0, height - bottomLeft * (1 - circleControl))} ${point(0, height - bottomLeft)}`,
+ };
+}
+
+function addSamples(times: Set, start: number, end: number) {
+ times.add(Number(start.toFixed(9)));
+ for (let sample = 1; start + sample / samplingRate < end; sample++) {
+ times.add(Number((start + sample / samplingRate).toFixed(9)));
+ }
+ times.add(Number(end.toFixed(9)));
+}
+
+export function sampleTimes(parts: readonly AssemblyPart[]) {
+ const times = new Set([0, 5.4]);
+ for (const part of parts) {
+ const { start, end } = travel(part);
+ addSamples(times, start, end);
+ }
+ return [...times].sort((a, b) => a - b);
+}
+
+export function outlineTimes(
+ _modules: readonly ModuleGeometry[],
+ axis: AssemblyAxis,
+ part: AssemblyPart,
+): number[] {
+ const participating: AssemblyPart[] = [part];
+ const edges: Edge[] =
+ axis === "vertical" ? ["top", "bottom"] : ["left", "right"];
+ for (const edge of edges) {
+ const neighbor = jointNeighbor(axis, part, edge);
+ if (neighbor !== undefined) participating.push(neighbor);
+ }
+ const times = new Set(sampleTimes(participating));
+ for (const edge of edges) {
+ const neighbor = jointNeighbor(axis, part, edge);
+ if (neighbor === undefined) continue;
+ const contact = jointTime(part, neighbor);
+ addSamples(times, contact, contact + jointDuration);
+ }
+ return [...times].sort((a, b) => a - b);
+}
diff --git a/apps/ui/components/particles-blueprint.tsx b/apps/ui/components/particles-blueprint.tsx
new file mode 100644
index 0000000000..a1451dd1e9
--- /dev/null
+++ b/apps/ui/components/particles-blueprint.tsx
@@ -0,0 +1,130 @@
+import { BlueprintDrawing } from "./blueprint-drawing";
+
+function Registration() {
+ return (
+
+
+
+ );
+}
+
+export function ParticlesBlueprint() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Studio
+
+
+ Public
+
+
+ Open
+
+
+ Studio
+
+
+ Public
+
+
+ Open
+
+
+
+
+
+ A / Identity
+
+
+ B / Visibility
+
+
+ C / Action
+
+
+ A + B + C
+
+
+ 3u
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/components/particles-motion.tsx b/apps/ui/components/particles-motion.tsx
new file mode 100644
index 0000000000..15c17e45b5
--- /dev/null
+++ b/apps/ui/components/particles-motion.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { BlueprintMotion } from "./blueprint-motion";
+import { ParticlesAssemblyDrawing } from "./particles-assembly-drawing";
+
+export function ParticlesMotion({ children }: { children: ReactNode }) {
+ return (
+ }>
+ {children}
+
+ );
+}
diff --git a/apps/ui/components/primitives-blueprint.tsx b/apps/ui/components/primitives-blueprint.tsx
new file mode 100644
index 0000000000..c213db2e12
--- /dev/null
+++ b/apps/ui/components/primitives-blueprint.tsx
@@ -0,0 +1,91 @@
+import { BlueprintDrawing } from "./blueprint-drawing";
+
+const gridCoordinates = [164, 204, 244, 284, 324, 364, 404];
+
+export function PrimitivesBlueprint() {
+ return (
+
+
+ {gridCoordinates.map((coordinate) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 6u
+
+
+ 6u
+
+
+ u
+
+
+ r = 3u
+
+
+ 45°
+
+
+ 01
+
+
+ 02
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/components/primitives-button.tsx b/apps/ui/components/primitives-button.tsx
new file mode 100644
index 0000000000..2a51c30d80
--- /dev/null
+++ b/apps/ui/components/primitives-button.tsx
@@ -0,0 +1,46 @@
+import {
+ ConstructionAnnotation,
+ ConstructionMotion,
+} from "./primitives-construction-motion";
+import { secondaryLine } from "./primitives-morph-style";
+
+export function ButtonConstruction() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1.5u
+
+
+ 04 / Button
+
+
+
+
+ );
+}
diff --git a/apps/ui/components/primitives-construction-motion.tsx b/apps/ui/components/primitives-construction-motion.tsx
new file mode 100644
index 0000000000..f0f8a69754
--- /dev/null
+++ b/apps/ui/components/primitives-construction-motion.tsx
@@ -0,0 +1,72 @@
+import type { ReactNode } from "react";
+import {
+ morphCycleDuration,
+ morphKeyTimes,
+ morphStageDuration,
+} from "./primitives-morph-timing";
+
+// Construction finishes during the first second of each 2.4s hold. Reset only
+// after its parent is hidden, so subsequent cycles never flash the final state.
+export function ConstructionMotion({
+ attributeName,
+ delay = 0.12,
+ duration = 0.68,
+ from,
+ stage,
+ to,
+ type,
+}: {
+ attributeName: string;
+ delay?: number;
+ duration?: number;
+ from: string | number;
+ stage: number;
+ to: string | number;
+ type?: "rotate";
+}) {
+ const timing = {
+ attributeName,
+ begin: `${stage * morphStageDuration}s`,
+ calcMode: "spline" as const,
+ dur: `${morphCycleDuration}s`,
+ keySplines: "0 0 1 1;0.22 0 0.12 1;0 0 1 1;0 0 1 1;0 0 1 1",
+ keyTimes: morphKeyTimes(
+ 0,
+ delay,
+ delay + duration,
+ 2.45,
+ 2.5,
+ morphCycleDuration,
+ ),
+ repeatCount: "indefinite",
+ values: [from, from, to, to, from, from].join(";"),
+ };
+
+ return type ? (
+
+ ) : (
+
+ );
+}
+
+export function ConstructionAnnotation({
+ children,
+ stage,
+}: {
+ children: ReactNode;
+ stage: number;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/apps/ui/components/primitives-morph-details.tsx b/apps/ui/components/primitives-morph-details.tsx
new file mode 100644
index 0000000000..61c7c9f2dd
--- /dev/null
+++ b/apps/ui/components/primitives-morph-details.tsx
@@ -0,0 +1,213 @@
+import { ButtonConstruction } from "./primitives-button";
+import {
+ ConstructionAnnotation,
+ ConstructionMotion,
+} from "./primitives-construction-motion";
+import { secondaryLine } from "./primitives-morph-style";
+import { SliderConstruction } from "./primitives-slider";
+import { SwitchConstruction } from "./primitives-switch";
+
+export const morphDetailIds = [
+ "circle",
+ "capsule",
+ "switch",
+ "button",
+ "rounded",
+ "slider",
+] as const;
+
+export function MorphDetails({
+ hatchId,
+ stage,
+}: {
+ hatchId: string;
+ stage: (typeof morphDetailIds)[number];
+}) {
+ if (stage === "circle") {
+ const clipId = `${hatchId}-sector`;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ r = 3u
+
+
+ 45°
+
+
+ 01 / Avatar
+
+
+
+
+ );
+ }
+
+ if (stage === "capsule") {
+ return (
+
+
+ {[204, 364].map((center) => (
+
+
+
+
+ ))}
+
+
+
+ {[204, 364].map((center) => (
+
+ ))}
+
+
+
+
+
+ 2u
+
+
+
+
+
+
+
+
+ 4u
+
+
+ 02 / Pill
+
+
+
+
+ );
+ }
+
+ if (stage === "switch") return ;
+
+ if (stage === "button") return ;
+
+ if (stage === "slider") return ;
+
+ if (stage === "rounded") {
+ return (
+
+ {/* A 1u inset of the R1u outline produces a sharp 4u square. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1u
+
+
+ 4u
+
+
+ 05 / Card
+
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/apps/ui/components/primitives-morph-style.ts b/apps/ui/components/primitives-morph-style.ts
new file mode 100644
index 0000000000..4e375694fb
--- /dev/null
+++ b/apps/ui/components/primitives-morph-style.ts
@@ -0,0 +1,7 @@
+// The pill’s diagonal radius is the reference for continuous secondary lines.
+export const secondaryLine = {
+ opacity: 0.5,
+ strokeWidth: 0.65,
+ strokeLinecap: "butt",
+ strokeLinejoin: "miter",
+} as const;
diff --git a/apps/ui/components/primitives-morph-timing.ts b/apps/ui/components/primitives-morph-timing.ts
new file mode 100644
index 0000000000..afb6704a58
--- /dev/null
+++ b/apps/ui/components/primitives-morph-timing.ts
@@ -0,0 +1,7 @@
+export const morphStageDuration = 3.2;
+export const morphHoldDuration = 2.4;
+export const morphCycleDuration = 6 * morphStageDuration;
+
+export function morphKeyTimes(...seconds: number[]) {
+ return seconds.map((second) => second / morphCycleDuration).join(";");
+}
diff --git a/apps/ui/components/primitives-morph-transitions.tsx b/apps/ui/components/primitives-morph-transitions.tsx
new file mode 100644
index 0000000000..f2c6527e88
--- /dev/null
+++ b/apps/ui/components/primitives-morph-transitions.tsx
@@ -0,0 +1,142 @@
+import { secondaryLine } from "./primitives-morph-style";
+import { morphCycleDuration as duration } from "./primitives-morph-timing";
+
+type Timing = { start: number; end: number };
+
+const circleToCapsule = { start: 2.4, end: 3.2 };
+const buttonToCard = { start: 12, end: 12.8 };
+
+function TransitionVisibility({ start, end }: Timing) {
+ return (
+ time / duration)
+ .join(";")}
+ repeatCount="indefinite"
+ values="0;0;1;1;0;0"
+ />
+ );
+}
+
+function TransitionMotion({
+ attributeName,
+ from,
+ to,
+ start,
+ end,
+}: Timing & {
+ attributeName: string;
+ from: string | number;
+ to: string | number;
+}) {
+ return (
+ time / duration)
+ .join(";")}
+ repeatCount="indefinite"
+ values={[from, from, to, to, from, from].join(";")}
+ />
+ );
+}
+
+const corners = [
+ { id: "top-right", x: 1, y: -1 },
+ { id: "bottom-right", x: 1, y: 1 },
+ { id: "bottom-left", x: -1, y: 1 },
+ { id: "top-left", x: -1, y: -1 },
+] as const;
+
+export function PrimitivesMorphTransitions() {
+ return (
+
+
+
+
+ {[204, 364].map((center) => (
+
+
+
+
+ ))}
+
+
+ {[204, 364].map((center) => (
+
+
+
+ ))}
+
+
+
+
+
+ {corners.map(({ id, x, y }) => (
+
+
+
+
+
+ ))}
+
+
+ {corners.map(({ id, x, y }) => (
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/ui/components/primitives-morph.tsx b/apps/ui/components/primitives-morph.tsx
new file mode 100644
index 0000000000..a32ac132bf
--- /dev/null
+++ b/apps/ui/components/primitives-morph.tsx
@@ -0,0 +1,305 @@
+"use client";
+
+import { useEffect, useId, useRef } from "react";
+import { BlueprintDrawing } from "./blueprint-drawing";
+import { MorphDetails, morphDetailIds } from "./primitives-morph-details";
+import { secondaryLine } from "./primitives-morph-style";
+import {
+ morphCycleDuration,
+ morphHoldDuration,
+ morphKeyTimes,
+ morphStageDuration,
+} from "./primitives-morph-timing";
+import { PrimitivesMorphTransitions } from "./primitives-morph-transitions";
+import { SliderGridMask } from "./primitives-slider";
+import { SwitchThumb } from "./primitives-switch";
+
+type Shape = { width: number; height: number; corner: number };
+
+// All stages share four straight edges and four cubic corners, including the
+// circle’s zero-length edges. Matching topology keeps every point continuous.
+function construct({ width, height, corner: r }: Shape) {
+ const left = 284 - width / 2;
+ const right = 284 + width / 2;
+ const top = 204 - height / 2;
+ const bottom = 204 + height / 2;
+ const a = r * 0.55228475;
+ const points = {
+ "top-left": [left + r, top],
+ "top-right": [right - r, top],
+ "right-top": [right, top + r],
+ "right-bottom": [right, bottom - r],
+ "bottom-right": [right - r, bottom],
+ "bottom-left": [left + r, bottom],
+ "left-bottom": [left, bottom - r],
+ "left-top": [left, top + r],
+ } as const;
+ return {
+ points,
+ showHeight: height >= 40 ? 1 : 0,
+ outline: `M${left + r} ${top} L${right - r} ${top}
+ C${right - r + a} ${top} ${right} ${top + r - a} ${right} ${top + r}
+ L${right} ${bottom - r}
+ C${right} ${bottom - r + a} ${right - r + a} ${bottom} ${right - r} ${bottom}
+ L${left + r} ${bottom}
+ C${left + r - a} ${bottom} ${left} ${bottom - r + a} ${left} ${bottom - r}
+ L${left} ${top + r}
+ C${left} ${top + r - a} ${left + r - a} ${top} ${left + r} ${top} Z`,
+ // One continuous guide owns each extension; measurement marks never retrace it.
+ guides: `M${left} 38V348 M${right} 38V348
+ M${left - 24} ${top}H490 M${left - 24} ${bottom}H490
+ M164 348V372 M204 348V372`,
+ dimensions: `M${left} 46H264 M304 46H${right}
+ M${left - 4} 50l8-8 M${right - 4} 50l8-8`,
+ heightDimension: `M482 ${top}V190 M482 218V${bottom}
+ M478 ${top + 4}l8-8 M478 ${bottom + 4}l8-8`,
+ };
+}
+
+const circle = construct({ width: 240, height: 240, corner: 120 });
+const pill = construct({ width: 320, height: 160, corner: 80 });
+const stages = [
+ circle,
+ pill,
+ pill,
+ construct({ width: 320, height: 160, corner: 24 }),
+ construct({ width: 240, height: 240, corner: 40 }),
+ construct({ width: 320, height: 12, corner: 6 }),
+ circle,
+];
+
+// Every 3.2s: hold the finished form for 2.4s, then morph over 0.8s.
+// Repeated frames share the same SVG clock with their construction details.
+function Morph({
+ attributeName,
+ values,
+}: {
+ attributeName: string;
+ values: (string | number)[];
+}) {
+ return (
+ ["0 0 1 1", "0.4 0 0.2 1"])
+ .join(";")}
+ keyTimes={morphKeyTimes(
+ ...values.flatMap((_, index) =>
+ index === values.length - 1
+ ? [morphCycleDuration]
+ : [
+ index * morphStageDuration,
+ index * morphStageDuration + morphHoldDuration,
+ ],
+ ),
+ )}
+ repeatCount="indefinite"
+ values={values
+ .flatMap((value, index) =>
+ index === values.length - 1 ? [value] : [value, value],
+ )
+ .join(";")}
+ />
+ );
+}
+
+const tangentIds = [
+ "top-left",
+ "top-right",
+ "right-top",
+ "right-bottom",
+ "bottom-right",
+ "bottom-left",
+ "left-bottom",
+ "left-top",
+] as const;
+
+const gridX = [124, 164, 204, 244, 284, 324, 364, 404, 444];
+const gridY = [84, 124, 164, 204, 244, 284, 324];
+
+export function PrimitivesMorph({ paused }: { paused: boolean }) {
+ const root = useRef(null);
+ const widthLabel = useRef(null);
+ const heightLabel = useRef(null);
+ const hatchId = useId();
+ const guideId = useId();
+ const gridMaskId = useId();
+
+ useEffect(() => {
+ const svg = root.current?.querySelector("svg");
+ if (!svg) return;
+ if (paused) svg.pauseAnimations();
+ else svg.unpauseAnimations();
+
+ const left = svg.querySelector(
+ '[data-anchor="left-bottom"]',
+ );
+ const right = svg.querySelector(
+ '[data-anchor="right-top"]',
+ );
+ const top = svg.querySelector('[data-anchor="top-left"]');
+ const bottom = svg.querySelector(
+ '[data-anchor="bottom-right"]',
+ );
+ let frame = 0;
+
+ function updateMeasurements() {
+ if (left && right && top && bottom) {
+ // Read the animated anchor coordinates, so numerical labels use exactly
+ // the same eased geometry as the SVG, including after pause/resume.
+ const width = `${Number(((right.cx.animVal.value - left.cx.animVal.value) / 40).toFixed(1))}u`;
+ const height = `${Number(((bottom.cy.animVal.value - top.cy.animVal.value) / 40).toFixed(1))}u`;
+ if (widthLabel.current && widthLabel.current.textContent !== width)
+ widthLabel.current.textContent = width;
+ if (heightLabel.current && heightLabel.current.textContent !== height)
+ heightLabel.current.textContent = height;
+ }
+ if (!paused) frame = requestAnimationFrame(updateMeasurements);
+ }
+
+ updateMeasurements();
+ return () => cancelAnimationFrame(frame);
+ }, [paused]);
+
+ return (
+
+
+
+
+ stage.guides)}
+ />
+
+ {/* Keep coincident grid lines from darkening the continuous guides. */}
+
+
+
+
+
+ {/* Origin at the circle center aligns a hatch line with its 45° radius. */}
+
+
+
+
+
+ {gridX.map((x) => (
+
+ ))}
+ {gridY.map((y) => (
+
+ ))}
+
+
+
+ stage.dimensions)}
+ />
+
+
+ stage.showHeight)}
+ />
+
+ stage.heightDimension)}
+ />
+
+
+
+ stage.outline)}
+ />
+
+
+ stage.showHeight)}
+ />
+ {tangentIds.map((id) => (
+
+ stage.points[id][0])}
+ />
+ stage.points[id][1])}
+ />
+
+ ))}
+
+
+ {morphDetailIds.map((stage, index) => (
+
+
+
+
+ ))}
+
+
+ 6u
+
+
+ stage.showHeight)}
+ />
+
+ 6u
+
+
+
+ u
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/components/primitives-motion.tsx b/apps/ui/components/primitives-motion.tsx
new file mode 100644
index 0000000000..2ded2416cc
--- /dev/null
+++ b/apps/ui/components/primitives-motion.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { BlueprintMotion } from "./blueprint-motion";
+import { PrimitivesMorph } from "./primitives-morph";
+
+export function PrimitivesMotion({ children }: { children: ReactNode }) {
+ return (
+ }
+ >
+ {children}
+
+ );
+}
diff --git a/apps/ui/components/primitives-slider.tsx b/apps/ui/components/primitives-slider.tsx
new file mode 100644
index 0000000000..a3d9006c54
--- /dev/null
+++ b/apps/ui/components/primitives-slider.tsx
@@ -0,0 +1,87 @@
+import {
+ ConstructionAnnotation,
+ ConstructionMotion,
+} from "./primitives-construction-motion";
+import { secondaryLine } from "./primitives-morph-style";
+import {
+ morphCycleDuration,
+ morphHoldDuration,
+ morphKeyTimes,
+ morphStageDuration,
+} from "./primitives-morph-timing";
+
+const travelGuides = "M204 116V292 M364 116V292 M204 164H364 M204 244H364";
+const scalePositions = [124, 164, 204, 244, 284, 324, 364, 404, 444];
+
+// Remove only coincident grid strokes, so the construction dashes retain gaps.
+export function SliderGridMask() {
+ return (
+
+
+
+
+ );
+}
+
+export function SliderConstruction() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {scalePositions.map((x, index) => {
+ const halfTick = index % 2 === 0 ? 4 : 2;
+ return ;
+ })}
+
+
+
+
+
+
+
+ 4u
+
+
+ 0%
+
+
+ 50%
+
+
+ 100%
+
+
+ 06 / Slider
+
+
+
+
+ );
+}
diff --git a/apps/ui/components/primitives-switch.tsx b/apps/ui/components/primitives-switch.tsx
new file mode 100644
index 0000000000..7918526b0c
--- /dev/null
+++ b/apps/ui/components/primitives-switch.tsx
@@ -0,0 +1,112 @@
+import { ConstructionAnnotation } from "./primitives-construction-motion";
+import { secondaryLine } from "./primitives-morph-style";
+import {
+ morphCycleDuration as duration,
+ morphKeyTimes as keyTimes,
+} from "./primitives-morph-timing";
+
+const radiusEndpoint = -60 / Math.sqrt(2);
+
+export function SwitchConstruction() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ 4u
+
+
+ 03 / Switch
+
+
+
+
+ );
+}
+
+export function SwitchThumb() {
+ return (
+
+
+
+ {/* Geometry resets after the whole thumb is hidden. */}
+
+
+
+
+
+
+
+
+ 1.5u
+
+
+
+
+ );
+}
diff --git a/apps/ui/components/site-header.tsx b/apps/ui/components/site-header.tsx
index 52cf2e81d8..51874e2476 100644
--- a/apps/ui/components/site-header.tsx
+++ b/apps/ui/components/site-header.tsx
@@ -12,6 +12,7 @@ export function SiteHeader() {
return (
@@ -26,11 +30,11 @@ export function SiteHeader({
{mobileNav}
{isExternal ? (
-
+
coss.com
) : (
-
+
coss.com
)}