# One pipeline, five shapes (Notchset): prompt.md (v2.0.0)

- id: `pipeline-shapes` · version 2.0.0 · block · free
- category: Marketing
- build: one set of files for every shadcn style
- install: `npx shadcn@latest add https://notchset.dev/r/pipeline-shapes.json`
- npm dependencies: none
- registry dependencies: utils, https://notchset.dev/r/notchset-foundation.json, @notchset/company-mark, @notchset/segmented-control
- docs: https://notchset.dev/blocks/pipeline-shapes
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

The same deals as a board, a list, the forecast's stacks, a month of close dates and the people who own them. Each deal travels to its place in the next shape and the total never changes, because the facts don't..

## Build it
- Stack: React 18.3 or 19, TypeScript, Tailwind CSS v4 utility classes on the foundation's roles, a components.json (imports use its aliases).
- Packages: none beyond React.
- Files: `components/ui/notchset/blocks/pipeline-shapes.tsx`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`, `company-mark`, `segmented-control`.
- Builds: one set of files, the same for every shadcn style.
- Exports to keep: `PipelineShapes`, and every exported type.
- CSS: the install adds the notchset foundation (its roles, keyframes and motion switch, reading your shadcn tokens for colour) to your global stylesheet once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- This item reads `--ns-bezel`, `--ns-ease`, `--ns-ease-panel`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-panel`, `--ns-radius`, `--ns-radius-sm`, `--ns-signal`, `--ns-text-meta`, `--ns-text-small`, `--ns-time-color`, `--ns-time-page`, `--ns-weight-medium`, `--ns-well`.
- Retune it (on any ancestor) with the foundation's roles and hooks: `--ns-bad`, `--ns-bad-ink`, `--ns-band`, `--ns-band-edge`, `--ns-band-grade`, `--ns-band-optimal`, `--ns-beat`, `--ns-bezel`, `--ns-canvas`, `--ns-cat-1`, `--ns-cat-2`, `--ns-cat-3`, `--ns-cat-4`, `--ns-cat-5`, `--ns-cat-6`, `--ns-check`, `--ns-chip`, `--ns-control`, `--ns-control-pad`, `--ns-control-sm`, `--ns-deep`, `--ns-deep-ink`, `--ns-deep-ink-2`, `--ns-deep-line`, `--ns-dialog`, `--ns-dialog-lg`, `--ns-dialog-sm`, `--ns-ease`, `--ns-ease-in`, `--ns-ease-panel`, `--ns-ease-pop`, `--ns-field`, `--ns-flood-arc`, `--ns-flood-aurora`, `--ns-flood-behind-1`, `--ns-flood-behind-2`, `--ns-flood-bloom`, `--ns-flood-deep`, `--ns-flood-dial-bloom`, `--ns-flood-glass`, `--ns-flood-grain`, `--ns-flood-image`, `--ns-flood-ink`, `--ns-flood-ink-low`, `--ns-flood-light`, `--ns-flood-mid`, `--ns-flood-pale`, `--ns-flood-pane`, `--ns-flood-pane-edge`, `--ns-flood-pill-ink`, `--ns-flood-ribbon`, `--ns-flood-ribbon-core`, `--ns-flood-shadow`, `--ns-flood-sheen`, `--ns-flood-wash`, `--ns-font-body`, `--ns-font-display`, `--ns-font-mono`, `--ns-font-script`, `--ns-glass`, `--ns-glass-blur`, `--ns-glass-shadow`, `--ns-glow`, `--ns-grain`, `--ns-gutter`, `--ns-header`, `--ns-heat`, `--ns-hover`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-ink-4`, `--ns-leading`, `--ns-lift`, `--ns-light-1`, `--ns-light-2`, `--ns-light-3`, `--ns-light-spread`, `--ns-light-strength`, `--ns-line`, `--ns-line-soft`, `--ns-lit-edge`, `--ns-menu-dy`, `--ns-menu-row`, `--ns-mesh`, `--ns-mesh-frost`, `--ns-ok`, `--ns-ok-ink`, `--ns-orb-body`, `--ns-orb-cool`, `--ns-orb-core`, `--ns-orb-dawn`, `--ns-orb-deep`, `--ns-orb-edge`, `--ns-orb-grain`, `--ns-orb-ground`, `--ns-orb-halo`, `--ns-orb-inner`, `--ns-orb-key`, `--ns-orb-lift`, `--ns-orb-limb`, `--ns-orb-limb-from`, `--ns-orb-rim`, `--ns-orb-skin-hi`, `--ns-orb-skin-lo`, `--ns-orb-skin-mid`, `--ns-orb-warm`, `--ns-panel`, `--ns-panel-w`, `--ns-prism-edge`, `--ns-prism-glint`, `--ns-prism-sheen`, `--ns-prism-tint`, `--ns-pulse`, `--ns-radius`, `--ns-radius-card`, `--ns-radius-full`, `--ns-radius-lg`, `--ns-radius-sm`, `--ns-radius-xl`, `--ns-raised`, `--ns-range`, `--ns-row`, `--ns-row-compact`, `--ns-segment`, `--ns-selected`, `--ns-series`, `--ns-series-core`, `--ns-series-lilac`, `--ns-series-tint`, `--ns-shadow-dialog`, `--ns-shadow-float`, `--ns-shadow-panel`, `--ns-sheet`, `--ns-sheet-bottom`, `--ns-side`, `--ns-signal`, `--ns-sleep-awake`, `--ns-sleep-deep`, `--ns-sleep-light`, `--ns-sleep-rem`, `--ns-space-1`, `--ns-space-2`, `--ns-space-3`, `--ns-space-4`, `--ns-space-5`, `--ns-space-6`, `--ns-space-8`, `--ns-space-section`, `--ns-stack-on`, `--ns-standing-critical`, `--ns-standing-in`, `--ns-standing-out`, `--ns-state-doing`, `--ns-state-done`, `--ns-state-review`, `--ns-state-todo`, `--ns-switch-h`, `--ns-switch-w`, `--ns-text-body`, `--ns-text-control`, `--ns-text-display`, `--ns-text-figure`, `--ns-text-headline`, `--ns-text-hero`, `--ns-text-lede`, `--ns-text-meta`, `--ns-text-page`, `--ns-text-record`, `--ns-text-small`, `--ns-text-strong`, `--ns-text-sub`, `--ns-text-title`, `--ns-tile-shadow`, `--ns-time-born`, `--ns-time-color`, `--ns-time-dialog`, `--ns-time-menu`, `--ns-time-page`, `--ns-time-panel`, `--ns-time-toast`, `--ns-tint`, `--ns-tint-ink`, `--ns-toast-bottom`, `--ns-toast-left`, `--ns-touch`, `--ns-tracking-display`, `--ns-tracking-headline`, `--ns-warn`, `--ns-warn-ink`, `--ns-wash-clay`, `--ns-wash-dusk`, `--ns-wash-graphite`, `--ns-wash-ink`, `--ns-wash-sage`, `--ns-wash-sand`, `--ns-weight-light`, `--ns-weight-medium`, `--ns-weight-regular`, `--ns-weight-strong`, `--ns-well`.
- Never add Tailwind colour classes inside the component.

```tsx
import { PipelineShapes, type ShapeDeal } from "@/components/ui/notchset/blocks/pipeline-shapes";
```

## Parts
- A single export that takes props.

## Match the original
- Read `components/ui/notchset/blocks/pipeline-shapes.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when

### Not when

## Built from
- company-mark (`company-mark`), installed with it: reuse it, don't rebuild it
- segmented-control (`segmented-control`), installed with it: reuse it, don't rebuild it

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import { PipelineShapes, type ShapeDeal } from "@/components/ui/notchset/blocks/pipeline-shapes";

const k = (v: number) => `$${v >= 1e6 ? `${(v / 1e6).toFixed(2).replace(/0$/, "")}M` : `${Math.round(v / 1000)}k`}`;
const DEALS: ShapeDeal[] = [
  { id: "p1", company: "Airtable", color: "#18bfff", title: "Team plan upgrade", value: 118000, stage: "Discovery", forecast: "Pipeline", close: "2026-11-06", owner: "Kevin Ito" },
  { id: "p2", company: "Webflow", color: "#146ef5", title: "Enterprise · 2-year", value: 215000, stage: "Evaluation", forecast: "Best case", close: "2026-11-30", owner: "Priya Raman" },
  { id: "p3", company: "Stripe", color: "#635bff", title: "Expansion · EU region", value: 152000, stage: "Proposal", forecast: "Commit", close: "2026-11-18", owner: "Omar Haddad" },
  { id: "p4", company: "Sentry", color: "#362d59", title: "Annual platform", value: 96000, stage: "Negotiation", forecast: "Commit", close: "2026-11-12", owner: "Priya Raman" },
  { id: "p5", company: "Revolut", color: "#191c1f", title: "Pilot to annual", value: 64000, stage: "Evaluation", forecast: "Pipeline", close: "2026-11-24", owner: "Mei Tanaka" },
  { id: "p6", company: "Dropbox", color: "#0061ff", title: "Seats for support", value: 48000, stage: "Proposal", forecast: "Best case", close: "2026-11-09", owner: "Kevin Ito" },
  { id: "p7", company: "PostHog", color: "#1d1f27", title: "Data residency", value: 132000, stage: "Negotiation", forecast: "Commit", close: "2026-11-20", owner: "Omar Haddad" },
  { id: "p8", company: "Framer", color: "#0055ff", title: "Agency workspace", value: 38000, stage: "Discovery", forecast: "Pipeline", close: "2026-11-27", owner: "Mei Tanaka" },
];

export default function PipelineShapesDemo() {
  return <PipelineShapes deals={DEALS} stages={["Discovery", "Evaluation", "Proposal", "Negotiation"]} format={k} month="2026-11" />;
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `deals` | `ShapeDeal[]` |  | Required. |
| `stages` | `string[]` |  | Required. |
| `format` | `(v: number) => string` |  | Required. |
| `month` | `string` |  | the month the calendar shows: YYYY-MM |

Full docs: https://notchset.dev/blocks/pipeline-shapes

## Customising
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- Dark mode follows the `.dark` class or `data-theme="dark"` on an ancestor (the shadcn and next-themes conventions).
- Update later: Commit or stash local changes first (or copy the files), so the update can be undone. Read https://notchset.dev/r/changelog.json for the new versions: a major version, an entry marked breaking or a merge says what to change. Then re-run the install with `--overwrite`, on a branch. It replaces the component's files, the shared helper files they import, and the item's CSS rules in the global stylesheet, so review the whole diff (components, shared files, CSS), re-apply your own edits, and run the app's typecheck, tests and build.

## Accessibility contract (preserve when editing)

## Install

```bash
npx shadcn@latest add https://notchset.dev/r/pipeline-shapes.json
```

Or add the `@notchset` registry to components.json and run `npx shadcn@latest add @notchset/pipeline-shapes`. Every components.json style gets the same files.

## Source

### components/ui/notchset/blocks/pipeline-shapes.tsx

```tsx
// Notchset pipeline-shapes 2.0.0 · https://notchset.dev/changelog
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { CompanyMark } from "@/components/ui/notchset/company-mark";
import { SegmentedControl } from "@/components/ui/notchset/segmented-control";

/*
 * One pipeline, five shapes: the same deals as a board, a list, the forecast's stacks, a month of close dates and
 * the people who own them. Each deal is one card that travels to its place in the next shape, so you watch the same
 * records rearrange; the total above never changes, because the facts don't. The deal you picked keeps its ring
 * through every shape. Positions come from the data, not from measuring, so the move is exact and cheap; reduced
 * motion lands each shape at once.
 *
 *   <PipelineShapes deals={deals} stages={["Discovery", "Evaluation", "Proposal", "Negotiation"]} format={money} />
 *
 * Capacity: every shape fits its fixed height, so no card covers another or leaves the box. A crowded column's cards
 * get shorter; a forecast stack keeps each deal's share but never runs above its column; a day with more deals than
 * its cell holds lays them side by side (each still a card, named in full to a screen reader).
 */
type ShapeDeal = {
  id: string;
  company: string;
  color: string;
  title: string;
  value: number;
  stage: string;
  forecast: "Commit" | "Best case" | "Pipeline";
  /** YYYY-MM-DD, all in the month shown */
  close: string;
  owner: string;
};
type Shape = "board" | "list" | "forecast" | "calendar" | "people";
type PipelineShapesProps = {
  deals: ShapeDeal[];
  stages: string[];
  format: (v: number) => string;
  /** the month the calendar shows: YYYY-MM */
  month: string;
  className?: string | undefined;
};
type Box = { x: number; y: number; w: number; h: number };

const SHAPES: { value: Shape; label: string }[] = [
  { value: "board", label: "Board" },
  { value: "list", label: "List" },
  { value: "forecast", label: "Forecast" },
  { value: "calendar", label: "Calendar" },
  { value: "people", label: "People" },
];
const FORECASTS = ["Commit", "Best case", "Pipeline"] as const;
const GAP = 8, HEAD = 26, H = 392;

/** Where every deal sits in a shape, and the labels that shape draws, for a stage `w` wide. */
function layout(shape: Shape, deals: ShapeDeal[], stages: string[], month: string, w: number, format: (v: number) => string) {
  const at: Record<string, Box> = {};
  const labels: { key: string; text: string; sum?: string; x: number; y: number; w: number }[] = [];
  const cells: (Box & { key: string; day: number })[] = [];
  const sum = (xs: ShapeDeal[]) => format(xs.reduce((a, d) => a + d.value, 0));
  const cols = (n: number) => {
    const cw = (w - GAP * (n - 1)) / n;
    return (i: number) => ({ x: i * (cw + GAP), w: cw });
  };
  if (shape === "board" || shape === "people") {
    const keys = shape === "board" ? stages : [...new Set(deals.map((d) => d.owner))];
    const col = cols(keys.length);
    const full = shape === "board" ? 58 : 34;
    keys.forEach((k, i) => {
      const c = col(i);
      const mine = deals.filter((d) => (shape === "board" ? d.stage : d.owner) === k);
      labels.push({ key: `${shape}-${k}`, text: shape === "people" ? k.split(" ")[0]! : k, sum: sum(mine), x: c.x, y: 0, w: c.w });
      /* a crowded column's cards get shorter, so the last one still ends inside the box */
      const gap = Math.min(6, (H - HEAD) / Math.max(1, mine.length) / 4), card = Math.min(full, (H - HEAD) / Math.max(1, mine.length) - gap);
      mine.forEach((d, j) => (at[d.id] = { x: c.x, y: HEAD + j * (card + gap), w: c.w, h: card }));
    });
  } else if (shape === "list") {
    const row = Math.min(34, (H - HEAD) / deals.length - 4);
    labels.push({ key: "list-h", text: "Deal", x: 0, y: 0, w: w / 2 });
    [...deals].sort((a, b) => b.value - a.value).forEach((d, i) => (at[d.id] = { x: 0, y: HEAD + i * (row + 4), w, h: row }));
  } else if (shape === "forecast") {
    const col = cols(3), max = Math.max(...FORECASTS.map((f) => deals.filter((d) => d.forecast === f).reduce((a, d) => a + d.value, 0)), 1);
    FORECASTS.forEach((f, i) => {
      const c = col(i), bw = Math.min(c.w, 140), x = c.x + (c.w - bw) / 2;
      labels.push({ key: `fc-${f}`, text: f, sum: sum(deals.filter((d) => d.forecast === f)), x: c.x, y: 0, w: c.w });
      /* each deal's share of the tallest stack, at least a readable sliver; when the slivers would push a stack past
         its column, the larger blocks give up the room, so the stack always fits */
      const mine = deals.filter((d) => d.forecast === f), room = H - HEAD - 8;
      const floor = Math.min(14, room / Math.max(1, mine.length) - 2);
      const hs = mine.map((d) => Math.max(floor, (Math.max(0, d.value) / max) * room - 2));
      const over = hs.reduce((a, h) => a + h + 2, 0) - room, give = hs.reduce((a, h) => a + h - floor, 0);
      const k = over > 0 && give > 0 ? Math.max(0, 1 - over / give) : 1;
      let y = H;
      mine.forEach((d, j) => {
        const h = floor + (hs[j]! - floor) * k;
        y -= h + 2;
        at[d.id] = { x, y, w: bw, h };
      });
    });
  } else {
    const [yy, mm] = month.split("-").map(Number) as [number, number];
    const first = new Date(Date.UTC(yy, mm - 1, 1)).getUTCDay(), col = cols(7);
    const weeks = Math.ceil((first + new Date(Date.UTC(yy, mm, 0)).getUTCDate()) / 7), rh = (H - HEAD) / weeks;
    ["S", "M", "T", "W", "T", "F", "S"].forEach((d, i) => labels.push({ key: `cal-${i}`, text: d, ...col(i), y: 0 }));
    const days = new Date(Date.UTC(yy, mm, 0)).getUTCDate();
    for (let day = 1; day <= days; day++) {
      const cell = first + day - 1, c = col(cell % 7);
      cells.push({ key: `d${day}`, day, x: c.x, y: HEAD + Math.floor(cell / 7) * rh, w: c.w, h: rh - 4 });
    }
    /* a day's cell holds as many 18px cards as fit under its number; more than that share the cell side by side */
    const fits = Math.max(1, Math.floor((rh - 4 - 18 + 2) / 20)), card = Math.min(18, rh - 4 - 18 - 2);
    const byDay = new Map<number, ShapeDeal[]>();
    deals.forEach((d) => byDay.set(+d.close.slice(8, 10), [...(byDay.get(+d.close.slice(8, 10)) ?? []), d]));
    byDay.forEach((ds, day) => {
      const cell = first + day - 1, c = col(cell % 7), across = Math.ceil(ds.length / fits), down = Math.ceil(ds.length / across);
      const cw = (c.w - 4 - (across - 1) * 2) / across, ch = Math.min(card, (rh - 4 - 18 - (down - 1) * 2) / down);
      ds.forEach((d, n) => (at[d.id] = { x: c.x + 2 + (n % across) * (cw + 2), y: HEAD + Math.floor(cell / 7) * rh + 18 + Math.floor(n / across) * (ch + 2), w: cw, h: ch }));
    });
  }
  return { at, labels, cells };
}

function PipelineShapes({ deals, stages, format, month, className }: PipelineShapesProps) {
  const [shape, setShape] = React.useState<Shape>("board");
  const [picked, setPicked] = React.useState<string | null>(deals[0]?.id ?? null);
  const box = React.useRef<HTMLDivElement>(null);
  const [w, setW] = React.useState(720);
  React.useEffect(() => {
    const el = box.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(([e]) => e && setW(Math.round(e.contentRect.width)));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  const { at, labels, cells } = React.useMemo(() => layout(shape, deals, stages, month, w, format), [shape, deals, stages, month, w, format]);
  const total = deals.reduce((a, d) => a + d.value, 0);
  const narrow = w < 560;
  return (
    <div data-slot="pipeline-shapes" className={cn("flex w-full min-w-0 flex-col gap-4", className)}>
      <div className="flex flex-wrap items-center justify-between gap-3">
        <span className="text-[length:var(--ns-text-small)] text-[var(--ns-ink-2)] tabular-nums">
          <b className="font-[number:var(--ns-weight-medium)] text-[var(--ns-ink)]">{format(total)}</b> · {deals.length} open deals
        </span>
        <SegmentedControl label="Shape" size="sm" options={SHAPES} value={shape} onValueChange={setShape} />
      </div>
      <div ref={box} className="relative w-full" style={{ height: H }}>
        {cells.map((c) => (
          <span key={c.key} aria-hidden data-ns-moves="" className="absolute rounded-[var(--ns-radius-sm)] bg-[color-mix(in_oklab,var(--ns-well)_70%,transparent)] px-1.5 pt-0.5 text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)] tabular-nums [animation:ns-fade_var(--ns-time-page)_var(--ns-ease)_both]" style={{ left: c.x, top: c.y, width: c.w, height: c.h }}>
            {c.day}
          </span>
        ))}
        {labels.map((l) => (
          <span key={l.key} aria-hidden data-ns-moves="" className="absolute flex items-baseline gap-1.5 text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)] [animation:ns-fade_var(--ns-time-page)_var(--ns-ease)_both]" style={{ left: l.x, top: l.y, width: l.w }}>
            <span className="truncate font-[number:var(--ns-weight-medium)] text-[var(--ns-ink-2)]">{l.text}</span>
            {l.sum && <span className="ml-auto shrink-0 tabular-nums">{l.sum}</span>}
          </span>
        ))}
        {deals.map((d, i) => {
          const b = at[d.id] ?? { x: 0, y: 0, w: 0, h: 0 };
          const roomy = b.h >= 50, tiny = b.h < 22 || b.w < 72;
          const on = picked === d.id;
          return (
            <button
              key={d.id}
              type="button"
              data-deal={d.id}
              aria-pressed={on}
              aria-label={`${d.company}, ${d.title}, ${format(d.value)}, ${d.stage}, ${d.forecast}, closes ${d.close}, ${d.owner}`}
              onClick={() => setPicked(on ? null : d.id)}
              data-ns-glide=""
              className={cn(
                "absolute top-0 left-0 flex cursor-pointer items-center overflow-hidden rounded-[var(--ns-radius)] bg-[var(--ns-panel)] text-left text-[var(--ns-ink)] outline-hidden [transition:transform_420ms_var(--ns-ease-panel),width_420ms_var(--ns-ease-panel),height_420ms_var(--ns-ease-panel),box-shadow_var(--ns-time-color)] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--ns-signal)]",
                tiny ? "gap-1 px-1.5" : "gap-2 px-2.5",
                on ? "z-10 shadow-[0_0_0_2px_var(--ns-signal),0_6px_16px_-8px_rgb(0_0_0/0.35)]" : "shadow-[var(--ns-bezel)]",
              )}
              style={{ transform: `translate(${b.x}px, ${b.y}px)`, width: b.w, height: b.h, transitionDelay: `${i * 14}ms` }}
            >
              <CompanyMark name={d.company} color={d.color} size={tiny ? 12 : 18} className="shrink-0" />
              <span className="flex min-w-0 flex-1 flex-col">
                <span className={cn("truncate font-[number:var(--ns-weight-medium)]", tiny ? "text-[length:var(--ns-text-meta)]" : "text-[length:var(--ns-text-small)]")}>{d.company}</span>
                {roomy && !narrow && <span className="truncate text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)]">{d.title}</span>}
              </span>
              {!tiny && <span className="shrink-0 text-[length:var(--ns-text-small)] tabular-nums">{format(d.value)}</span>}
            </button>
          );
        })}
      </div>
      <span aria-live="polite" className="sr-only">
        {SHAPES.find((s) => s.value === shape)!.label}
      </span>
    </div>
  );
}

export { PipelineShapes, type PipelineShapesProps, type ShapeDeal };
```

The foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://notchset.dev/r/notchset-foundation.json.
