An Artemis-II-inspired palette of 16 colours is shuffled once per mount and dealt into 10 procedural compositions -- single circle, cluster, spiral, wreath, scattered squares, grid, diagonal strip, spectrum bar, diamonds, and Gaussian cloud. Each click spring-animates all 100 shape slots (stiffness 70, damping 18, staggered by index) to the next layout; shapes not used by a composition collapse to size 0 with visibility hidden. Border-radius toggles between circles and squares per composition.
Source
// Artemis II inspired color palette
export const COLORS = [
"#4A6FA5", // blue
"#C1272D", // red
"#E87D2F", // orange
"#D4A76A", // tan
"#8B6914", // dark gold
"#A8B88C", // sage green
"#2C2C2C", // near black
"#7B8794", // grey
"#5B7553", // olive
"#8FBCE6", // light blue
"#1B2B5A", // navy
"#C4A882", // khaki
"#D4785C", // terracotta
"#FFFFFF", // white
"#3D3D3D", // dark grey
"#B8472A", // dark red
];
export interface ShapeState {
x: number;
y: number;
size: number;
rotation: number;
borderRadius: number; // 50 = circle, 0 = square
color: string;
}
// Max shapes across all compositions — excess ones collapse to size 0
export const MAX_SHAPES = 100;
type CompositionFn = (
width: number,
height: number,
colors: string[]
) => ShapeState[];
// Base unit — all sizes scale from this
const unit = (width: number, height: number) => Math.min(width, height) * 0.01;
// Helper: pad a shape array to MAX_SHAPES, collapsing extras to center at size 0
function padToMax(
shapes: ShapeState[],
width: number,
height: number
): ShapeState[] {
const cx = width / 2;
const cy = height / 2;
const result = [...shapes];
while (result.length < MAX_SHAPES) {
result.push({
x: cx,
y: cy,
size: 0,
rotation: 0,
borderRadius: 50,
color: shapes[result.length % shapes.length]?.color ?? "#FFFFFF",
});
}
return result;
}
// Composition 0: All shapes collapsed to center — only first has size
const singleCircle: CompositionFn = (width, height) => {
const cx = width / 2;
const cy = height / 2;
const u = unit(width, height);
return Array.from({ length: MAX_SHAPES }, (_, i) => ({
x: cx,
y: cy,
size: i === 0 ? u * 18 : 0,
rotation: 0,
borderRadius: 50,
color: i === 0 ? "#FFFFFF" : COLORS[i % COLORS.length],
}));
};
// Composition 1: Clustered circles — 16 shapes
const clusteredCircles: CompositionFn = (width, height, colors) => {
const count = 16;
const cx = width / 2;
const cy = height / 2;
const u = unit(width, height);
const spread = u * 22;
const shapes = Array.from({ length: count }, (_, i) => {
const angle = (i / count) * Math.PI * 2 + Math.random() * 0.5;
const dist = Math.random() * spread;
const size = u * (6 + Math.random() * 22);
return {
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist,
size,
rotation: 0,
borderRadius: 50,
color: colors[i % colors.length],
};
});
return padToMax(shapes, width, height);
};
// Composition 2: Spiral — 60 shapes, arc-length spaced
const spiral: CompositionFn = (width, height, colors) => {
const count = 60;
const cx = width / 2;
const cy = height / 2;
const u = unit(width, height);
// Pre-compute sizes: small in center, large on outside
const sizes = Array.from({ length: count }, (_, i) => {
const t = i / (count - 1);
return u * (2.5 + t * 10);
});
// Walk the spiral, spacing circles along the arm with slight overlap.
// Growth rate increases with angle so outer revolutions are spaced wider apart.
const shapes: ShapeState[] = [];
let angle = 0;
for (let i = 0; i < count; i++) {
const size = sizes[i];
const halfSize = size / 2;
// Spiral radius grows faster as angle increases (quadratic term)
const spiralR = u * 1.0 * angle + u * 0.06 * angle * angle;
shapes.push({
x: cx + Math.cos(angle) * spiralR,
y: cy + Math.sin(angle) * spiralR,
size,
rotation: 0,
borderRadius: 50,
color: colors[i % colors.length],
});
// Advance angle: step along arc by ~75% of combined radii (slight overlap)
const nextSize = sizes[Math.min(i + 1, count - 1)];
const step = (halfSize + nextSize / 2) * 0.75;
const r = Math.max(spiralR, u * 4);
angle += step / r;
}
return padToMax(shapes, width, height);
};
// Composition 3: Wreath / ring of circles — 80 shapes
const wreath: CompositionFn = (width, height, colors) => {
const count = 80;
const cx = width / 2;
const cy = height / 2;
const u = unit(width, height);
const ringRadius = u * 30;
const ringThickness = u * 12;
const shapes = Array.from({ length: count }, (_, i) => {
const angle = (i / count) * Math.PI * 2;
const radiusJitter = (Math.random() - 0.5) * ringThickness;
const dist = ringRadius + radiusJitter;
const size = u * (3 + Math.random() * 9);
return {
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist,
size,
rotation: 0,
borderRadius: 50,
color: colors[i % colors.length],
};
});
return padToMax(shapes, width, height);
};
// Composition 4: Scattered squares — 70 shapes
const scatteredSquares: CompositionFn = (width, height, colors) => {
const count = 70;
const u = unit(width, height);
const margin = u * 8;
const shapes = Array.from({ length: count }, (_, i) => {
const size = u * (1.5 + Math.random() * 7);
return {
x: margin + Math.random() * (width - margin * 2),
y: margin + Math.random() * (height - margin * 2),
size,
rotation: Math.random() * 45,
borderRadius: 0,
color: colors[i % colors.length],
};
});
return padToMax(shapes, width, height);
};
// Composition 5: Grid of rectangles — 12 shapes
const gridRectangles: CompositionFn = (width, height, colors) => {
const cols = 4;
const rows = 3;
const count = cols * rows;
const u = unit(width, height);
const gridW = u * 75;
const gridH = u * 58;
const cellW = gridW / cols;
const cellH = gridH / rows;
const offsetX = (width - gridW) / 2;
const offsetY = (height - gridH) / 2;
const shapes = Array.from({ length: count }, (_, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
return {
x: offsetX + col * cellW + cellW / 2,
y: offsetY + row * cellH + cellH / 2,
size: Math.min(cellW, cellH) * 0.92,
rotation: 0,
borderRadius: 0,
color: colors[i % colors.length],
};
});
return padToMax(shapes, width, height);
};
// Composition 6: Diagonal strips — 24 shapes
const diagonalStrips: CompositionFn = (width, height, colors) => {
const count = 24;
const u = unit(width, height);
const shapes = Array.from({ length: count }, (_, i) => {
const t = i / count;
const size = u * (3 + Math.random() * 14);
return {
x: width * 0.1 + t * width * 0.8,
y: height * 0.1 + t * height * 0.8 + (Math.random() - 0.5) * u * 16,
size,
rotation: -45 + Math.random() * 10,
borderRadius: 0,
color: colors[i % colors.length],
};
});
return padToMax(shapes, width, height);
};
// Composition 7: Horizontal color bar — 16 shapes
const colorBar: CompositionFn = (width, height, colors) => {
const count = 16;
const u = unit(width, height);
const barWidth = u * 80;
const barHeight = u * 14;
const segmentWidth = barWidth / count;
const startX = (width - barWidth) / 2;
const cy = height / 2;
const shapes = Array.from({ length: count }, (_, i) => ({
x: startX + i * segmentWidth + segmentWidth / 2,
y: cy,
size: Math.min(segmentWidth * 1.05, barHeight),
rotation: 0,
borderRadius: 0,
color: colors[i % colors.length],
}));
return padToMax(shapes, width, height);
};
// Composition 8: Overlapping diamonds — 20 shapes
const diamonds: CompositionFn = (width, height, colors) => {
const count = 20;
const cx = width / 2;
const cy = height / 2;
const u = unit(width, height);
const shapes = Array.from({ length: count }, (_, i) => {
const angle = (i / count) * Math.PI * 2;
const dist = u * (8 + Math.random() * 26);
const size = u * (5 + Math.random() * 16);
return {
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist,
size,
rotation: 45,
borderRadius: 0,
color: colors[i % colors.length],
};
});
return padToMax(shapes, width, height);
};
// Composition 9: Dense cloud — 100 shapes
const cloud: CompositionFn = (width, height, colors) => {
const count = 100;
const cx = width / 2;
const cy = height / 2;
const u = unit(width, height);
const spread = u * 30;
const shapes = Array.from({ length: count }, (_, i) => {
const r1 = Math.random();
const r2 = Math.random();
const gaussX = Math.sqrt(-2 * Math.log(r1)) * Math.cos(2 * Math.PI * r2);
const gaussY = Math.sqrt(-2 * Math.log(r1)) * Math.sin(2 * Math.PI * r2);
const size = u * (2 + Math.random() * 9);
return {
x: cx + gaussX * spread * 0.4,
y: cy + gaussY * spread * 0.4,
size,
rotation: 0,
borderRadius: 50,
color: colors[i % colors.length],
};
});
return padToMax(shapes, width, height);
};
export const compositions: CompositionFn[] = [
singleCircle,
clusteredCircles,
spiral,
wreath,
scatteredSquares,
gridRectangles,
diagonalStrips,
colorBar,
diamonds,
cloud,
];
export const compositionNames = [
"ORIGIN",
"CLUSTER",
"SPIRAL",
"WREATH",
"SCATTER",
"GRID",
"DIAGONAL",
"SPECTRUM",
"DIAMOND",
"CLOUD",
];
"use client";
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
import {
compositions,
compositionNames,
COLORS,
MAX_SHAPES,
} from "./compositions";
export default function Playground() {
const [compositionIndex, setCompositionIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const [dimensions, setDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const [seed] = useState(() => Math.random());
const stageRef = useRef<HTMLDivElement>(null);
// Measure the stage itself, not the window — inside the detail-page frame
// (or the HQ desktop) the fixed inset-0 root is contained to a smaller box.
useEffect(() => {
const el = stageRef.current;
if (!el) return;
const update = () =>
setDimensions({ width: el.clientWidth, height: el.clientHeight });
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const shapes = useMemo(() => {
if (!dimensions) return [];
const seededColors = [...COLORS].sort(
() => Math.sin(seed * 1000) - 0.5
);
const compositionFn = compositions[compositionIndex];
return compositionFn(dimensions.width, dimensions.height, seededColors);
}, [compositionIndex, dimensions, seed]);
const handleClick = useCallback(() => {
if (isTransitioning) return;
setIsTransitioning(true);
setCompositionIndex((prev) => (prev + 1) % compositions.length);
setTimeout(() => setIsTransitioning(false), 800);
}, [isTransitioning]);
return (
<div
ref={stageRef}
className="fixed inset-0 cursor-pointer select-none"
onClick={handleClick}
>
{shapes.map((shape, i) => (
<motion.div
key={`shape-${i}`}
initial={false}
animate={{
left: shape.x,
top: shape.y,
width: shape.size,
height: shape.size,
rotate: shape.rotation,
borderRadius: `${shape.borderRadius}%`,
}}
transition={{
type: "spring",
stiffness: 70,
damping: 18,
mass: 0.8 + (i % 20) * 0.05,
delay: (i / MAX_SHAPES) * 0.5,
}}
className="absolute"
style={{
backgroundColor: shape.color,
x: "-50%",
y: "-50%",
visibility: shape.size === 0 ? "hidden" : "visible",
}}
/>
))}
{/* UI overlay */}
<div className="fixed bottom-8 left-8 pointer-events-none font-[family-name:var(--font-flux)]" style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}>
<motion.div
key={compositionNames[compositionIndex]}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<p className="text-[12px] tracking-[0.08em] text-foreground/40 uppercase">
{compositionNames[compositionIndex]}
</p>
<p className="text-[12px] tracking-[0.2em] text-foreground/25 mt-1">
{compositionIndex + 1} / {compositions.length}
</p>
</motion.div>
</div>
<div className="fixed bottom-8 right-8 pointer-events-none font-[family-name:var(--font-flux)]" style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}>
<p className="text-[12px] tracking-[0.2em] text-foreground/25 uppercase">
Click anywhere
</p>
</div>
<div className="fixed top-8 left-8 z-10">
<ScrambleLink
from="NEMAWASHI LAB"
to="← BACK"
href="/lab"
className="text-[14px] tracking-[0.08em] text-foreground/60 uppercase pointer-events-auto font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}