jacquardSnapshot

← 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>
  );
}