{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-summary",
  "title": "AI Summary",
  "description": "AI Summary: the banner that marks AI-authored copy — one sentence of plain language on an animated vector field, in one of four palettes. The painting is a gradient plus colour masses under a fractal-noise warp, so a palette is a few hex values and three durations. No runtime dependencies.",
  "registryDependencies": [
    "https://sc1m.vercel.app/r/tokens.json",
    "https://sc1m.vercel.app/r/cn.json"
  ],
  "files": [
    {
      "path": "src/components/ui/ai-summary/ai-summary.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/**\n * AI Summary — the banner that marks AI-authored copy.\n *\n * The summary sits on a painting. Each field is one gradient plus a handful\n * of colour masses pushed through a fractal-noise displacement warp, with a\n * grain pass on top — so a palette is a few hex values and three durations,\n * and nothing is ever re-rendered or fetched.\n *\n * A veil in `--fg` sits between the painting and the copy. It is what makes\n * the text legible, so it is per-palette rather than a constant: a hot field\n * needs a flat 72%, a deep one can be graded and let its top keep colour.\n *\n * The palettes are deliberately raw hex. They are paint, not semantics —\n * there is no brand meaning to inherit, and pinning them to tokens would make\n * every brand's field the same picture. Everything structural around them\n * (the veil, the copy, the radius, the accent in the marks) is tokens.\n */\n\ntype Mass = {\n  /** centre [cx, cy] in the 680×500 view box */\n  c: [number, number];\n  /** radii [rx, ry] */\n  r: [number, number];\n  fill: string;\n  /** opacity */\n  o: number;\n  /** SMIL drifts as [attribute, seconds, values] */\n  anim?: [string, number, string][];\n};\n\ninterface Field {\n  gradient: { x1: number; y1: number; x2: number; y2: number };\n  stops: [number, string][];\n  warp: {\n    freq: string;\n    octaves: number;\n    seed: number;\n    blur: number;\n    freqDur: number;\n    freqValues: string;\n    scale: number;\n    scaleDur: number;\n    scaleValues: string;\n  };\n  grain: {\n    freq: number;\n    octaves: number;\n    seed: number;\n    slope: number;\n    opacity: number;\n  };\n  driftDur: number;\n  veil: string;\n  masses: Mass[];\n}\n\nexport const aiSummaryPalettes = {\n  ember: {\n    gradient: { x1: 0, y1: 0, x2: 0.4, y2: 1 },\n    stops: [[0, \"#8c3a20\"], [0.33, \"#c1512a\"], [0.67, \"#e0975c\"], [1, \"#9c4430\"]],\n    warp: { freq: \"0.010 0.022\", octaves: 4, seed: 4, blur: 8,\n      freqDur: 26, freqValues: \"0.010 0.022; 0.016 0.014; 0.008 0.026; 0.010 0.022\",\n      scale: 140, scaleDur: 34, scaleValues: \"140;180;112;140\" },\n    grain: { freq: 0.9, octaves: 3, seed: 15, slope: 0.16, opacity: 0.55 },\n    driftDur: 38,\n    veil: \"color-mix(in oklab, var(--fg) 72%, transparent)\",\n    masses: [\n      { c: [160, 70], r: [270, 74], fill: \"#6f2a1a\", o: 0.75, anim: [[\"cx\", 29, \"160;250;100;160\"]] },\n      { c: [520, 110], r: [240, 60], fill: \"#e56c33\", o: 0.7, anim: [[\"ry\", 28, \"60;90;48;60\"]] },\n      { c: [340, 60], r: [210, 32], fill: \"#f7b96e\", o: 0.72, anim: [[\"opacity\", 25, \"0.72;0.36;0.83;0.72\"]] },\n      { c: [120, 220], r: [250, 64], fill: \"#8e3a5c\", o: 0.66, anim: [[\"cx\", 50, \"120;210;60;120\"]] },\n      { c: [570, 260], r: [230, 54], fill: \"#f7e0c6\", o: 0.55, anim: [[\"ry\", 43, \"54;81;43;54\"]] },\n      { c: [300, 320], r: [290, 44], fill: \"#b8452e\", o: 0.62 },\n      { c: [200, 420], r: [300, 72], fill: \"#7c3320\", o: 0.75, anim: [[\"cx\", 71, \"200;290;140;200\"], [\"opacity\", 41, \"0.75;0.38;0.86;0.75\"]] },\n      { c: [600, 450], r: [240, 56], fill: \"#e0a06a\", o: 0.6, anim: [[\"ry\", 58, \"56;84;45;56\"]] },\n      { c: [430, 190], r: [150, 20], fill: \"#ffd79c\", o: 0.62 },\n    ],\n  },\n  tide: {\n    gradient: { x1: 0, y1: 0, x2: 0.25, y2: 1 },\n    stops: [[0, \"#0f3660\"], [0.33, \"#1c5f9e\"], [0.67, \"#7fb6d8\"], [1, \"#2a5f8f\"]],\n    warp: { freq: \"0.007 0.026\", octaves: 5, seed: 9, blur: 6,\n      freqDur: 31, freqValues: \"0.007 0.026; 0.011 0.017; 0.006 0.030; 0.007 0.026\",\n      scale: 96, scaleDur: 39, scaleValues: \"96;128;78;96\" },\n    grain: { freq: 0.85, octaves: 3, seed: 22, slope: 0.14, opacity: 0.5 },\n    driftDur: 44,\n    veil:\n      \"linear-gradient(to top, color-mix(in oklab, var(--fg) 92%, transparent) 0%, color-mix(in oklab, var(--fg) 72%, transparent) 48%, color-mix(in oklab, var(--fg) 46%, transparent) 100%)\",\n    masses: [\n      { c: [140, 80], r: [280, 66], fill: \"#08243f\", o: 0.7, anim: [[\"cx\", 33, \"140;230;80;140\"]] },\n      { c: [540, 140], r: [250, 52], fill: \"#2b82c4\", o: 0.72, anim: [[\"ry\", 30, \"52;78;42;52\"]] },\n      { c: [320, 40], r: [220, 28], fill: \"#a7d5e9\", o: 0.62, anim: [[\"opacity\", 27, \"0.62;0.3;0.72;0.62\"]] },\n      { c: [100, 240], r: [260, 58], fill: \"#1a4f8c\", o: 0.72, anim: [[\"cx\", 54, \"100;190;50;100\"]] },\n      { c: [580, 280], r: [220, 46], fill: \"#cdeaf8\", o: 0.5, anim: [[\"ry\", 47, \"46;70;37;46\"]] },\n      { c: [280, 330], r: [300, 40], fill: \"#ecd9ae\", o: 0.45 },\n      { c: [180, 430], r: [310, 68], fill: \"#0b2c50\", o: 0.78, anim: [[\"cx\", 77, \"180;270;120;180\"], [\"opacity\", 45, \"0.78;0.42;0.88;0.78\"]] },\n      { c: [610, 460], r: [250, 54], fill: \"#5f95bd\", o: 0.55, anim: [[\"ry\", 62, \"54;81;43;54\"]] },\n      { c: [400, 200], r: [160, 18], fill: \"#f4f9fb\", o: 0.5 },\n    ],\n  },\n  moss: {\n    gradient: { x1: 0, y1: 0, x2: 0.12, y2: 1 },\n    stops: [[0, \"#2c5038\"], [0.33, \"#4e7c48\"], [0.67, \"#a8ba78\"], [1, \"#3a6350\"]],\n    warp: { freq: \"0.012 0.016\", octaves: 4, seed: 17, blur: 9,\n      freqDur: 35, freqValues: \"0.012 0.016; 0.018 0.011; 0.009 0.021; 0.012 0.016\",\n      scale: 118, scaleDur: 46, scaleValues: \"118;152;92;118\" },\n    grain: { freq: 0.95, octaves: 3, seed: 31, slope: 0.18, opacity: 0.6 },\n    driftDur: 52,\n    veil:\n      \"linear-gradient(200deg, color-mix(in oklab, var(--fg) 52%, transparent) 0%, color-mix(in oklab, var(--fg) 78%, transparent) 55%, color-mix(in oklab, var(--fg) 90%, transparent) 100%)\",\n    masses: [\n      { c: [150, 60], r: [260, 70], fill: \"#27452f\", o: 0.76, anim: [[\"cx\", 41, \"150;240;90;150\"]] },\n      { c: [530, 130], r: [240, 56], fill: \"#5f9450\", o: 0.68, anim: [[\"ry\", 37, \"56;84;45;56\"]] },\n      { c: [330, 70], r: [200, 30], fill: \"#d2da96\", o: 0.6, anim: [[\"opacity\", 33, \"0.6;0.3;0.7;0.6\"]] },\n      { c: [110, 230], r: [250, 60], fill: \"#356b6b\", o: 0.64, anim: [[\"cx\", 63, \"110;200;60;110\"]] },\n      { c: [560, 270], r: [230, 50], fill: \"#eee6c6\", o: 0.48, anim: [[\"ry\", 55, \"50;75;40;50\"]] },\n      { c: [300, 320], r: [280, 42], fill: \"#8c6f37\", o: 0.55 },\n      { c: [190, 425], r: [300, 70], fill: \"#22402f\", o: 0.78, anim: [[\"cx\", 84, \"190;280;130;190\"], [\"opacity\", 51, \"0.78;0.4;0.88;0.78\"]] },\n      { c: [600, 450], r: [240, 54], fill: \"#6f8f6a\", o: 0.6, anim: [[\"ry\", 69, \"54;81;43;54\"]] },\n      { c: [420, 185], r: [150, 18], fill: \"#f3f1d6\", o: 0.52 },\n    ],\n  },\n  ink: {\n    gradient: { x1: 0, y1: 0, x2: 0.3, y2: 1 },\n    stops: [[0, \"#0a0a0a\"], [0.33, \"#161310\"], [0.67, \"#241a12\"], [1, \"#0f0d0c\"]],\n    warp: { freq: \"0.006 0.012\", octaves: 3, seed: 3, blur: 12,\n      freqDur: 44, freqValues: \"0.006 0.012; 0.009 0.008; 0.005 0.015; 0.006 0.012\",\n      scale: 72, scaleDur: 56, scaleValues: \"72;98;58;72\" },\n    grain: { freq: 0.8, octaves: 3, seed: 7, slope: 0.12, opacity: 0.45 },\n    driftDur: 60,\n    veil:\n      \"linear-gradient(200deg, color-mix(in oklab, var(--fg) 20%, transparent) 0%, color-mix(in oklab, var(--fg) 46%, transparent) 100%)\",\n    masses: [\n      { c: [200, 120], r: [300, 90], fill: \"#f05a1e\", o: 0.5, anim: [[\"cx\", 47, \"200;280;150;200\"], [\"opacity\", 39, \"0.5;0.28;0.58;0.5\"]] },\n      { c: [560, 340], r: [280, 80], fill: \"#8a3b12\", o: 0.45, anim: [[\"ry\", 53, \"80;110;66;80\"]] },\n      { c: [340, 240], r: [220, 40], fill: \"#ffb066\", o: 0.3, anim: [[\"opacity\", 61, \"0.3;0.16;0.36;0.3\"]] },\n    ],\n  },\n} as const satisfies Record<string, Field>;\n\nexport type AiSummaryPalette = keyof typeof aiSummaryPalettes;\n\n/** False during SSR and the first client paint, true once hydrated. */\nconst subscribeMotion = (onChange: () => void) => {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n};\n\n/**\n * Whether the painting may animate.\n *\n * SMIL ignores `prefers-reduced-motion` — a CSS rule cannot stop `<animate>`\n * — so the timelines have to be left out of the DOM entirely. The server\n * snapshot is `false`, which means the first paint is always the still\n * painting and the motion is added on hydration: no mismatch, and a reader\n * who asked for no motion never gets a frame of it.\n */\nfunction useMayAnimate() {\n  return React.useSyncExternalStore(\n    subscribeMotion,\n    () => !window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/** The painting itself: gradient, warped colour masses, grain. */\nfunction VectorField({\n  palette,\n  animate,\n}: {\n  palette: AiSummaryPalette;\n  animate: boolean;\n}) {\n  const id = React.useId().replace(/[^a-zA-Z0-9]/g, \"\");\n  const f = aiSummaryPalettes[palette] as Field;\n\n  return (\n    <svg\n      viewBox=\"0 0 680 500\"\n      preserveAspectRatio=\"xMidYMid slice\"\n      aria-hidden\n      className=\"absolute inset-0 block size-full\"\n    >\n      <defs>\n        <linearGradient id={`${id}-base`} {...f.gradient}>\n          {f.stops.map(([offset, color]) => (\n            <stop key={offset} offset={offset} stopColor={color} />\n          ))}\n        </linearGradient>\n\n        {/* The warp: fractal noise displaces the masses, then a blur melts\n            the displaced edges back into each other. Animating the noise\n            frequency and the displacement scale on different periods is what\n            keeps the field from looping visibly. */}\n        <filter\n          id={`${id}-paint`}\n          x=\"-20%\"\n          y=\"-20%\"\n          width=\"140%\"\n          height=\"140%\"\n          colorInterpolationFilters=\"sRGB\"\n        >\n          <feTurbulence\n            type=\"fractalNoise\"\n            baseFrequency={f.warp.freq}\n            numOctaves={f.warp.octaves}\n            seed={f.warp.seed}\n            result=\"n\"\n          >\n            {animate && (\n              <animate\n                attributeName=\"baseFrequency\"\n                dur={`${f.warp.freqDur}s`}\n                repeatCount=\"indefinite\"\n                values={f.warp.freqValues}\n                calcMode=\"spline\"\n                keyTimes=\"0;0.34;0.68;1\"\n                keySplines=\"0.4 0 0.6 1;0.4 0 0.6 1;0.4 0 0.6 1\"\n              />\n            )}\n          </feTurbulence>\n          <feDisplacementMap\n            in=\"SourceGraphic\"\n            in2=\"n\"\n            scale={f.warp.scale}\n            xChannelSelector=\"R\"\n            yChannelSelector=\"G\"\n            result=\"warp\"\n          >\n            {animate && (\n              <animate\n                attributeName=\"scale\"\n                dur={`${f.warp.scaleDur}s`}\n                repeatCount=\"indefinite\"\n                values={f.warp.scaleValues}\n                calcMode=\"spline\"\n                keyTimes=\"0;0.33;0.66;1\"\n                keySplines=\"0.4 0 0.6 1;0.4 0 0.6 1;0.4 0 0.6 1\"\n              />\n            )}\n          </feDisplacementMap>\n          <feGaussianBlur in=\"warp\" stdDeviation={f.warp.blur} />\n        </filter>\n\n        <filter\n          id={`${id}-grain`}\n          x=\"0\"\n          y=\"0\"\n          width=\"100%\"\n          height=\"100%\"\n          colorInterpolationFilters=\"sRGB\"\n        >\n          <feTurbulence\n            type=\"fractalNoise\"\n            baseFrequency={f.grain.freq}\n            numOctaves={f.grain.octaves}\n            seed={f.grain.seed}\n          />\n          <feColorMatrix type=\"saturate\" values=\"0\" />\n          <feComponentTransfer>\n            <feFuncA type=\"linear\" slope={f.grain.slope} />\n          </feComponentTransfer>\n        </filter>\n      </defs>\n\n      <rect width=\"680\" height=\"500\" fill={`url(#${id}-base)`} />\n\n      <g\n        filter={`url(#${id}-paint)`}\n        className=\"vf-drift\"\n        style={{ animationDuration: `${f.driftDur}s` }}\n      >\n        {f.masses.map((m, i) => (\n          <ellipse\n            key={i}\n            cx={m.c[0]}\n            cy={m.c[1]}\n            rx={m.r[0]}\n            ry={m.r[1]}\n            fill={m.fill}\n            opacity={m.o}\n          >\n            {animate &&\n              m.anim?.map(([attr, dur, values]) => (\n                <animate\n                  key={attr}\n                  attributeName={attr}\n                  dur={`${dur}s`}\n                  repeatCount=\"indefinite\"\n                  values={values}\n                />\n              ))}\n          </ellipse>\n        ))}\n      </g>\n\n      <rect\n        width=\"680\"\n        height=\"500\"\n        filter={`url(#${id}-grain)`}\n        opacity={f.grain.opacity}\n        className=\"vf-shimmer\"\n      />\n    </svg>\n  );\n}\n\n/**\n * The card: painting, veil, then the copy.\n *\n * `isolate` matters — the veil and the content are positioned siblings of the\n * SVG, and without a stacking context of its own the card would let a\n * portalled popup elsewhere on the page slide between them.\n */\nexport function AiSummary({\n  palette = \"ink\",\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  /** Which painting to hang behind the copy. */\n  palette?: AiSummaryPalette;\n}) {\n  const animate = useMayAnimate();\n\n  return (\n    <div\n      className={cn(\n        \"relative isolate overflow-hidden rounded-lg bg-fg\",\n        className,\n      )}\n      {...props}\n    >\n      <VectorField palette={palette} animate={animate} />\n      <div\n        aria-hidden\n        className=\"absolute inset-0\"\n        style={{ background: aiSummaryPalettes[palette].veil }}\n      />\n      <div className=\"relative px-[22px] pt-5 pb-[22px]\">{children}</div>\n    </div>\n  );\n}\n\n/** Eyebrow: names the angle, in the page background colour at low contrast. */\nexport function AiSummaryLabel({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      className={cn(\n        \"font-mono text-[9.5px] font-bold tracking-[0.16em] text-bg/[0.66] uppercase\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** Body copy. Looser leading than AS1 — it is sitting on a picture. */\nexport function AiSummaryText({\n  className,\n  ...props\n}: React.ComponentProps<\"p\">) {\n  return (\n    <p\n      className={cn(\n        \"mt-3 text-[15px]/[1.85] text-pretty text-bg/[0.86]\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/**\n * The phrase that carries the finding, highlighted in the accent.\n *\n * Static: the highlight is there from the first paint. It is a reading aid,\n * not an event — sweeping it in pulls the eye to the mark at a moment the\n * reader has not reached yet, and repeats on every remount.\n *\n * `box-decoration-break: clone` keeps the pill intact when the phrase wraps.\n */\nexport function AiSummaryMark({\n  className,\n  ...props\n}: React.ComponentProps<\"mark\">) {\n  return (\n    <mark\n      className={cn(\"ai-mark rounded-[3px] px-[5px] py-0.5 text-bg\", className)}\n      {...props}\n    />\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}