← snapshot
12458 bytes
import type { ReactNode } from "react";
/**
* Jackie's face: a kaleidoscope of woven damask.
*
* Three ideas, all borrowed from the loom rather than invented:
*
* 1. **Self-similar.** Jacquard damask is built by repeating one motif at
* several scales — the same figure inside itself, smaller and turned.
* Every motif here is generated recursively, so the figure at the rim is
* the figure at the spindle.
* 2. **Kaleidoscopic.** One wedge is drawn, then instanced around the circle
* with alternating mirroring — how a kaleidoscope's mirrors, and a
* medallion draft, make symmetry out of a single repeat.
* 3. **The figure names the subject.** Each thing Jackie can show you gets
* its own motif and its own fold count, so the pattern tells you what is
* under review before you read a word: a barred lattice for the gate,
* punched holes for a file, an ornate arch for a decision, chevrons for a
* snapshot, interlace for the cloth, a branching thread while she asks.
*
* Colour stays orthogonal and keeps its own meaning: indigo is the agent
* thread and gold is the human one, so the same figure runs indigo while she
* speaks and gold while she is listening to you.
*/
export type OrbState = "idle" | "speaking" | "listening" | "thinking";
/** What is under review. Drives the figure, not the colour. */
export type OrbSubject =
| "lobby"
| "gate"
| "file"
| "decision"
| "snapshot"
| "cloth"
| "interview"
| "draft";
type Builder = (depth: number, scale: number, key: string) => ReactNode;
interface MotifSpec {
/** Mirror-repeats around the circle. */
sectors: number;
depth: number;
build: Builder;
/** Rotation period in seconds at rest; state scales this down. */
period: number;
}
/* ------------------------------------------------------------------ *
* The motifs. Each one contains itself, smaller and turned.
* ------------------------------------------------------------------ */
/** Nested rings carrying smaller rings — the resting figure. */
function medallion(depth: number, scale: number, key: string): ReactNode {
if (depth <= 0 || scale < 0.08) return null;
const r = 26 * scale;
return (
<g key={key}>
<circle
cx={0}
cy={-r}
r={r * 0.62}
fill="none"
stroke="currentColor"
strokeWidth={0.9 * scale + 0.22}
opacity={0.22 + depth * 0.14}
/>
<g transform={`translate(0 ${-r * 1.7})`}>
{medallion(depth - 1, scale * 0.55, `${key}c`)}
</g>
<g transform={`translate(${r * 0.6} ${-r * 0.6}) rotate(48)`}>
{medallion(depth - 1, scale * 0.42, `${key}d`)}
</g>
<g transform={`translate(${-r * 0.6} ${-r * 0.6}) rotate(-48)`}>
{medallion(depth - 1, scale * 0.42, `${key}e`)}
</g>
</g>
);
}
/** The gate: a barred lattice. Uprights crossed by rails, all the way down. */
function portcullis(depth: number, scale: number, key: string): ReactNode {
if (depth <= 0 || scale < 0.08) return null;
const len = 32 * scale;
const w = 1.1 * scale + 0.26;
const o = 0.22 + depth * 0.16;
return (
<g key={key}>
<line
x1={0}
y1={0}
x2={0}
y2={-len}
stroke="currentColor"
strokeWidth={w}
opacity={o}
strokeLinecap="square"
/>
<line
x1={-len * 0.3}
y1={-len * 0.36}
x2={len * 0.3}
y2={-len * 0.36}
stroke="currentColor"
strokeWidth={w}
opacity={o}
strokeLinecap="square"
/>
<line
x1={-len * 0.2}
y1={-len * 0.72}
x2={len * 0.2}
y2={-len * 0.72}
stroke="currentColor"
strokeWidth={w}
opacity={o * 0.85}
strokeLinecap="square"
/>
<g transform={`translate(0 ${-len})`}>
{portcullis(depth - 1, scale * 0.5, `${key}u`)}
</g>
<g transform={`translate(${len * 0.3} ${-len * 0.36}) rotate(78)`}>
{portcullis(depth - 1, scale * 0.3, `${key}r`)}
</g>
<g transform={`translate(${-len * 0.3} ${-len * 0.36}) rotate(-78)`}>
{portcullis(depth - 1, scale * 0.3, `${key}l`)}
</g>
</g>
);
}
/**
* A file: punched holes on a card.
*
* Deliberately the sparsest figure here — discrete filled holes on a rule,
* with only one branch per level. Curved motifs at a high fold count all
* converge on "rosette"; this one has to stay legible as *punched card* next
* to the decision's damask, so it stays dotty, low-fold, and open.
*/
function perforation(depth: number, scale: number, key: string): ReactNode {
if (depth <= 0 || scale < 0.07) return null;
const spread = 30 * scale;
const r = 3.1 * scale + 0.7;
return (
<g key={key}>
{/* the rule the holes are punched along */}
<line
x1={0}
y1={-spread * 0.18}
x2={0}
y2={-spread * 0.98}
stroke="currentColor"
strokeWidth={0.5 * scale + 0.14}
opacity={0.16}
/>
{[0.3, 0.62, 0.94].map((f, i) => (
<circle
key={`${key}h${String(i)}`}
cx={0}
cy={-spread * f}
r={r}
fill="currentColor"
opacity={0.34 + depth * 0.15}
/>
))}
{/* one branch only — two would close the figure into lace */}
<g transform={`translate(0 ${-spread * 1.14}) rotate(24)`}>
{perforation(depth - 1, scale * 0.46, `${key}a`)}
</g>
</g>
);
}
/** A decision: the lobed arch, sprouting arches from its shoulders. */
function damask(depth: number, scale: number, key: string): ReactNode {
if (depth <= 0 || scale < 0.08) return null;
const r = 30 * scale;
return (
<g key={key}>
<path
d={`M0 ${-r * 0.15} q ${r * 0.55} ${-r * 0.5} 0 ${-r} q ${-r * 0.55} ${r * 0.5} 0 ${r}`}
fill="none"
stroke="currentColor"
strokeWidth={1.15 * scale + 0.25}
opacity={0.24 + depth * 0.15}
/>
<circle cx={0} cy={-r * 0.86} r={1.5 * scale + 0.5} fill="currentColor" opacity={0.5} />
<g transform={`translate(${r * 0.34} ${-r * 0.52}) rotate(34)`}>
{damask(depth - 1, scale * 0.52, `${key}a`)}
</g>
<g transform={`translate(${-r * 0.34} ${-r * 0.52}) rotate(-34)`}>
{damask(depth - 1, scale * 0.52, `${key}b`)}
</g>
</g>
);
}
/** A snapshot: the pick's path, chevrons of chevrons. */
function shuttle(depth: number, scale: number, key: string): ReactNode {
if (depth <= 0 || scale < 0.08) return null;
const s = 26 * scale;
return (
<g key={key}>
<path
d={`M${-s * 0.5} ${-s * 0.35} L0 ${-s} L${s * 0.5} ${-s * 0.35}`}
fill="none"
stroke="currentColor"
strokeWidth={1.05 * scale + 0.24}
opacity={0.22 + depth * 0.16}
strokeLinejoin="round"
/>
<g transform={`translate(0 ${-s * 1.05}) scale(0.6)`}>
{shuttle(depth - 1, scale, `${key}u`)}
</g>
<g transform={`translate(${s * 0.62} ${-s * 0.3}) rotate(52) scale(0.42)`}>
{shuttle(depth - 1, scale, `${key}p`)}
</g>
<g transform={`translate(${-s * 0.62} ${-s * 0.3}) rotate(-52) scale(0.42)`}>
{shuttle(depth - 1, scale, `${key}q`)}
</g>
</g>
);
}
/** The cloth: two strands going over and under, at every scale. */
function interlace(depth: number, scale: number, key: string): ReactNode {
if (depth <= 0 || scale < 0.08) return null;
const s = 30 * scale;
const w = 1.1 * scale + 0.24;
const o = 0.2 + depth * 0.15;
return (
<g key={key}>
<path
d={`M${-s * 0.55} ${-s * 0.18} Q 0 ${-s * 0.66} ${s * 0.55} ${-s * 0.18}`}
fill="none"
stroke="currentColor"
strokeWidth={w}
opacity={o}
strokeLinecap="round"
/>
<path
d={`M${-s * 0.55} ${-s * 0.54} Q 0 ${-s * 0.06} ${s * 0.55} ${-s * 0.54}`}
fill="none"
stroke="currentColor"
strokeWidth={w}
opacity={o * 0.8}
strokeLinecap="round"
/>
<g transform={`translate(0 ${-s * 0.72})`}>
{interlace(depth - 1, scale * 0.58, `${key}u`)}
</g>
</g>
);
}
/** While she asks: a thread branching into finer threads. */
function warp(depth: number, scale: number, key: string): ReactNode {
if (depth <= 0 || scale < 0.08) return null;
const len = 34 * scale;
return (
<g key={key}>
<line
x1={0}
y1={0}
x2={0}
y2={-len}
stroke="currentColor"
strokeWidth={1.1 * scale + 0.22}
opacity={0.2 + depth * 0.16}
strokeLinecap="round"
/>
<g transform={`translate(0 ${-len}) rotate(26)`}>
{warp(depth - 1, scale * 0.62, `${key}l`)}
</g>
<g transform={`translate(0 ${-len}) rotate(-26)`}>
{warp(depth - 1, scale * 0.62, `${key}r`)}
</g>
</g>
);
}
/**
* Subject → figure. The fold count is part of the identity: the gate is
* four-fold and rigid, the cloth sixteen-fold and dense, the interview an odd
* five so it never quite closes.
*/
const MOTIFS: Record<OrbSubject, MotifSpec> = {
lobby: { sectors: 8, depth: 3, build: medallion, period: 44 },
gate: { sectors: 4, depth: 4, build: portcullis, period: 60 },
file: { sectors: 7, depth: 4, build: perforation, period: 38 },
decision: { sectors: 12, depth: 4, build: damask, period: 34 },
snapshot: { sectors: 6, depth: 3, build: shuttle, period: 30 },
cloth: { sectors: 16, depth: 3, build: interlace, period: 50 },
interview: { sectors: 5, depth: 4, build: warp, period: 26 },
draft: { sectors: 12, depth: 3, build: damask, period: 40 },
};
/** State only changes tempo — the figure belongs to the subject. */
const TEMPO: Record<OrbState, number> = {
idle: 1,
speaking: 0.45,
listening: 0.28,
thinking: 0.12,
};
export function JackieOrbSvg({
state,
subject = "lobby",
level = 0,
size = 132,
}: {
state: OrbState;
subject?: OrbSubject;
level?: number;
size?: number;
}) {
const active = state === "speaking" || state === "listening";
const thread = state === "listening" ? "var(--jac-human)" : "var(--jac-agent)";
const spec = MOTIFS[subject];
const wedgeId = `jac-wedge-${subject}`;
const period = Math.max(2.5, spec.period * TEMPO[state]);
// Every sector is the same wedge, turned — and every other one mirrored,
// which is what makes it read as a kaleidoscope rather than a pinwheel.
const sectors = Array.from({ length: spec.sectors }, (_, i) => {
const angle = (360 / spec.sectors) * i;
const mirrored = i % 2 === 1;
return (
<use
key={i}
href={`#${wedgeId}`}
transform={`rotate(${angle} 60 60)${mirrored ? " translate(120 0) scale(-1 1)" : ""}`}
/>
);
});
return (
<div
className="jac-orb"
data-state={state}
data-subject={subject}
style={
{
width: size,
height: size,
color: thread,
"--orb-thread": thread,
"--orb-swell": String(active ? 1 + level * 0.05 : 1),
"--orb-period": `${period}s`,
"--orb-period-counter": `${period * 0.62}s`,
} as React.CSSProperties
}
aria-hidden="true"
>
{/* keyed on the subject so the figure re-enters rather than morphing */}
<svg key={subject} viewBox="0 0 120 120" width={size} height={size}>
<defs>
<radialGradient id={`jac-orb-core-${subject}`}>
<stop offset="0%" stopColor={thread} stopOpacity="0.4" />
<stop offset="65%" stopColor={thread} stopOpacity="0.07" />
<stop offset="100%" stopColor={thread} stopOpacity="0" />
</radialGradient>
{/* one wedge, drawn once, instanced around the circle */}
<g id={wedgeId}>
<g transform="translate(60 60)">{spec.build(spec.depth, 1, "m")}</g>
</g>
</defs>
<circle cx="60" cy="60" r="58" fill={`url(#jac-orb-core-${subject})`} />
{/* the kaleidoscope, counter-rotating against its own reflection */}
<g className="jac-orb-glass">{sectors}</g>
<g className="jac-orb-glass jac-orb-glass--counter" opacity={0.5}>
{sectors}
</g>
{/* the spindle */}
<circle
cx="60"
cy="60"
r={7 + level * 6}
fill={thread}
opacity={active ? 0.85 : 0.45}
/>
</svg>
</div>
);
}