# Flowchart

> Workflow trigger and condition cards on a dotted canvas with draggable steps.

- **Slug:** `flowchart`
- **Kind:** component · **Category:** Data
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/Flowchart.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/flowchart
- **All files in one bundle:** https://viewership.ai/ui/components/flowchart.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/flowchart.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy these files into `src/components/viewership/flowchart/`:

   - `Flowchart.astro` (raw: https://viewership.ai/ui/raw/flowchart/Flowchart.astro.txt)
   - `layout.ts` (raw: https://viewership.ai/ui/raw/flowchart/layout.ts.txt)

npm packages: none.

## Usage

```astro
---
import Flowchart from "../components/viewership/flowchart/Flowchart.astro";
---

<Flowchart />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import Flowchart, { type FlowEdge, type FlowStep } from "./Flowchart.astro";

const properties = ["channel", "topic", "tone", "format"].map((name) => ({ name }));

const steps: FlowStep[] = [
  {
    id: "trigger",
    row: 0,
    x: 0.5,
    w: 300,
    kind: { label: "Trigger", hue: "accent" },
    hue: "accent",
    icon: "package",
    title: "New content request",
    caption: "Trigger when a client submits a request",
  },
  {
    id: "cond",
    row: 1,
    x: 0.5,
    w: 356,
    kind: { label: "If / Else", hue: "amber" },
    conditions: [
      {
        join: "If",
        source: "request",
        property: { value: "channel", options: properties },
        value: {
          value: "Reddit",
          options: [
            { name: "Reddit", tag: "Threads" },
            { name: "YouTube", tag: "Video" },
            { name: "LinkedIn", tag: "Social" },
            { name: "Email", tag: "Owned" },
          ],
        },
      },
      {
        join: "and",
        source: "request",
        property: { value: "tone", options: properties },
        wrap: true,
        value: {
          value: "Plain and direct, with sample numbers",
          options: [
            { name: "Plain and direct, with sample numbers" },
            { name: "Warm brand voice" },
            { name: "Technical and precise" },
            { name: "Short and punchy" },
          ],
        },
      },
    ],
  },
];

const edges: FlowEdge[] = [{ from: "trigger", to: "cond" }];
---

<div class="stage">
  <Flowchart steps={steps} edges={edges} labels={{ canvas: "Content request workflow" }} />
</div>

<style>
  .stage {
    max-width: 560px;
    margin-inline: auto;
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `steps` | `FlowStep[]` | `[]` | Cards: { id, row, x (0–1 centre), w (px), kind?: { label, hue }, hue?, icon?, title?, caption?, conditions? }. A step with `conditions` is a condition card. |
| `edges` | `{ from: string; to: string }[]` | `[]` | Connectors from the bottom of `from` to the top of `to`. |
| `sourceIcon` | `"spark" \| "package" \| "flame"` | `"spark"` | Icon on the condition record chip. |
| `labels` | `{ canvas?, moveHint? }` |  | Canvas name and keyboard-move hint for step cards. |

## Source

### `src/components/viewership/flowchart/Flowchart.astro`

```astro
---
/**
 * Flowchart: an agent workflow on a dotted canvas. Step cards and condition
 * cards (If / and rows of select chips) are joined by bezier connectors.
 * Cards drag freely and connectors follow. Condition chips open listbox
 * dropdowns.
 *
 * Adapted from Beautiful UI Flowchart (MIT, © 2026 Shane Levine).
 *
 * Keyboard: arrow keys nudge a focused step card (Shift = bigger steps);
 * chips follow the listbox pattern (Arrow/Home/End/Enter/Escape).
 *
 * Events on the root:
 *   vs-flowchart-select  detail: { id | null }
 *   vs-flowchart-change  detail: { node, condition, field: "property" | "value", value }
 */
import type { HTMLAttributes } from "astro/types";
import { PILL_OFFSET, rowLayout, type LayoutNode } from "./layout";

export type FlowIcon = "spark" | "package" | "flame";
/** Named hues, or any CSS color. */
export type FlowHue = "violet" | "amber" | "accent" | "green" | "red" | "orange" | (string & {});
export type FlowOption = { name: string; tag?: string };
export type FlowSelect = {
  value: string;
  options: FlowOption[];
  /** Menu width in px. Defaults: 144 property, 176 value, 256 wrapped value. */
  menuWidth?: number;
  /** Which edge of the chip the menu aligns to. */
  align?: "left" | "right";
};
export type FlowCondition = {
  /** Row lead word: "If", "and", "or". */
  join: string;
  /** Record chip, e.g. "order". */
  source: string;
  property: FlowSelect;
  /** Comparison word between property and value. */
  operator?: string;
  value: FlowSelect;
  /** Put the value chip on its own indented line (for long values). */
  wrap?: boolean;
};
export type FlowStep = {
  id: string;
  /** Vertical row (0 = top). */
  row: number;
  /** Horizontal centre, 0–1 of the canvas width. */
  x: number;
  /** Card width in px (clamped to 92% of the canvas). */
  w: number;
  kind?: { label: string; hue: FlowHue };
  hue?: FlowHue;
  icon?: FlowIcon;
  title?: string;
  caption?: string;
  /** When set, renders a condition card instead of a step card. */
  conditions?: FlowCondition[];
};
export type FlowEdge = { from: string; to: string };

type Props = HTMLAttributes<"div"> & {
  steps?: FlowStep[];
  edges?: FlowEdge[];
  /** Icon on the condition rows' record chip. */
  sourceIcon?: FlowIcon;
  labels?: { canvas?: string; moveHint?: string };
};

const { steps = [], edges = [], sourceIcon = "spark", labels, class: className, ...rest } = Astro.props;
const text = { canvas: "Workflow", moveHint: "Arrow keys move this card.", ...labels };

const HUES: Record<string, string> = {
  violet: "oklch(0.5 0.13 355)",
  amber: "var(--vs-orange)",
  accent: "var(--vs-accent)",
  green: "var(--vs-green)",
  red: "var(--vs-red)",
  orange: "var(--vs-orange)",
};
const hue = (h?: FlowHue) => (h ? (HUES[h] ?? h) : "var(--vs-accent)");

const ICONS: Record<FlowIcon, string> = {
  spark:
    '<path d="M12 3l1.8 7.2L21 12l-7.2 1.8L12 21l-1.8-7.2L3 12l7.2-1.8z"/>',
  package:
    '<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="m7.5 4.27 9 5.15"/>',
  flame:
    '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
};

/* first-paint layout from estimated heights; the script measures and corrects */
const estimate = (s: FlowStep) => {
  const pill = s.kind ? PILL_OFFSET : 0;
  if (!s.conditions) return pill + 56;
  const lines = s.conditions.reduce((n, c) => n + (c.wrap ? 2 : 1), 0);
  return pill + 20 + lines * 24 + Math.max(lines - 1, 0) * 6;
};
const nodes: LayoutNode[] = steps.map((s) => ({ id: s.id, row: s.row, x: s.x, w: s.w, kind: Boolean(s.kind) }));
const heights = Object.fromEntries(steps.map((s) => [s.id, estimate(s)]));
const { canvasH, topOf } = rowLayout(nodes, heights);
const byId = new Map(nodes.map((n) => [n.id, n]));
const staticEdges = edges
  .map((e) => {
    const a = byId.get(e.from);
    const b = byId.get(e.to);
    if (!a || !b) return null;
    return { x1: a.x * 100, y1: topOf(a) + heights[a.id], x2: b.x * 100, y2: topOf(b) + (b.kind ? PILL_OFFSET : 0) };
  })
  .filter((e) => e !== null);

const uid = `vs-flow-${Math.random().toString(36).slice(2, 8)}`;
const config = JSON.stringify({ nodes, edges, heights });
---

<div
  class:list={["vs-flowchart", className]}
  role="group"
  aria-label={text.canvas}
  style={`height: ${canvasH}px`}
  {...rest}
>
  <script type="application/json" data-vs-config set:html={config} />
  <span class="sr-only" id={`${uid}-hint`}>{text.moveHint}</span>

  <svg class="edges" aria-hidden="true" width="100%" height="100%">
    {staticEdges.map((e) => <line class="edge edge-static" x1={`${e.x1}%`} y1={e.y1} x2={`${e.x2}%`} y2={e.y2} />)}
    {edges.map(() => <path class="edge" d="" />)}
  </svg>

  {steps.map((step) => {
    const n = byId.get(step.id)!;
    return (
      <div
        class="node"
        data-node={step.id}
        style={`left: ${step.x * 100}%; top: ${topOf(n)}px; width: min(${step.w}px, 92%); --hue: ${hue(step.hue)}`}
      >
        {step.kind && (
          <span class="kind" style={`--hue: ${hue(step.kind.hue)}`}>
            {step.kind.label}
          </span>
        )}
        {step.conditions ? (
          <div class="card card--condition">
            <div class="conditions">
              {step.conditions.map((c, ci) => {
                type ChipSpec = { field: "property" | "value"; sel: FlowSelect; dot: boolean; width: number; align: "left" | "right" };
                const selects: ChipSpec[] = [
                  { field: "property", sel: c.property, dot: false, width: 144, align: "left" },
                  { field: "value", sel: c.value, dot: true, width: 176, align: "right" },
                ];
                const chip = (s: ChipSpec) => {
                  const id = `${uid}-${step.id}-${ci}-${s.field}`;
                  return (
                    <span class="select" data-ui data-node-id={step.id} data-condition={ci} data-field={s.field}>
                      <button
                        type="button"
                        class="chip"
                        aria-haspopup="listbox"
                        aria-expanded="false"
                        aria-controls={id}
                        aria-label={`${c.join} ${c.source} ${s.field === "property" ? "property" : c.property.value}: ${s.sel.value}`}
                      >
                        {s.dot && <span class="dot" style={`--hue: ${hue(step.kind?.hue ?? step.hue)}`}></span>}
                        <span class="chip-value">{s.sel.value}</span>
                        <svg aria-hidden="true" class="chevron" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"></path></svg>
                      </button>
                      <div
                        class:list={["menu", `menu--${s.sel.align ?? s.align}`]}
                        id={id}
                        role="listbox"
                        aria-label={s.field === "property" ? "Property" : c.property.value}
                        style={`width: ${s.sel.menuWidth ?? (c.wrap && s.field === "value" ? 256 : s.width)}px`}
                        hidden
                      >
                        <span class="glide" aria-hidden="true"></span>
                        {s.sel.options.map((o) => (
                          <div role="option" class="option" tabindex="-1" aria-selected={o.name === s.sel.value ? "true" : "false"} data-name={o.name}>
                            <span class="option-name">{o.name}</span>
                            {o.tag && <span class="option-tag">{o.tag}</span>}
                            <svg aria-hidden="true" class="check" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg>
                          </div>
                        ))}
                      </div>
                    </span>
                  );
                };
                return (
                  <div class:list={["cond-row", c.wrap && "cond-row--wrap"]}>
                    <svg aria-hidden="true" class="handle" width="10" height="16" viewBox="0 0 10 16" fill="currentColor">
                      {[3, 8, 13].map((y) => (
                        <>
                          <circle cx="3" cy={y} r="1.1" />
                          <circle cx="7.5" cy={y} r="1.1" />
                        </>
                      ))}
                    </svg>
                    <span class="join">{c.join}</span>
                    <span class="source" data-ui>
                      <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" set:html={ICONS[sourceIcon]} />
                      {c.source}
                    </span>
                    {chip(selects[0])}
                    <span class="join-op">{c.operator ?? "is"}</span>
                    {c.wrap ? <span class="wrap-value">{chip({ ...selects[1], align: "left" })}</span> : chip(selects[1])}
                  </div>
                );
              })}
            </div>
          </div>
        ) : (
          <button type="button" class="card card--step" aria-pressed="false" aria-describedby={`${uid}-hint`}>
            <span class="step-icon" aria-hidden="true">
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" set:html={ICONS[step.icon ?? "spark"]} />
            </span>
            <span class="step-text">
              <span class="step-title">{step.title}</span>
              <span class="step-caption">{step.caption}</span>
            </span>
          </button>
        )}
      </div>
    );
  })}
</div>

<script>
  import { bezier, PILL_OFFSET, rowLayout, type LayoutNode } from "./layout";

  type Config = { nodes: LayoutNode[]; edges: { from: string; to: string }[]; heights: Record<string, number> };
  const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), Math.max(lo, hi));

  function init(root: HTMLElement) {
    const configEl = root.querySelector<HTMLScriptElement>("script[data-vs-config]");
    if (!configEl) return;
    const { nodes, edges, heights } = JSON.parse(configEl.textContent || "{}") as Config;
    const svg = root.querySelector<SVGSVGElement>("svg.edges")!;
    svg.querySelectorAll(".edge-static").forEach((l) => l.remove());
    const paths = [...svg.querySelectorAll<SVGPathElement>("path.edge")];
    const els = new Map(nodes.map((n) => [n.id, root.querySelector<HTMLElement>(`[data-node="${CSS.escape(n.id)}"]`)!]));
    const offsets: Record<string, { dx: number; dy: number }> = {};
    let width = root.clientWidth || 480;
    let selected: string | null = null;
    let layout = rowLayout(nodes, heights);

    const place = (n: LayoutNode) => {
      const off = offsets[n.id];
      return { w: Math.min(n.w, width * 0.92), cx: n.x * width + (off?.dx ?? 0), top: layout.topOf(n) + (off?.dy ?? 0) };
    };
    const anchors = (n: LayoutNode) => {
      const { cx, top } = place(n);
      return { top: { x: cx, y: top + (n.kind ? PILL_OFFSET : 0) }, bottom: { x: cx, y: top + (heights[n.id] ?? 90) } };
    };

    const render = () => {
      layout = rowLayout(nodes, heights);
      root.style.height = `${layout.canvasH}px`;
      svg.setAttribute("width", String(width));
      svg.setAttribute("height", String(layout.canvasH));
      for (const n of nodes) {
        const el = els.get(n.id);
        if (!el) continue;
        const { w, cx, top } = place(n);
        el.style.left = `${cx}px`;
        el.style.top = `${top}px`;
        el.style.width = `${w}px`;
      }
      edges.forEach((e, i) => {
        const a = nodes.find((n) => n.id === e.from);
        const b = nodes.find((n) => n.id === e.to);
        if (!a || !b || !paths[i]) return;
        paths[i].setAttribute("d", bezier(anchors(a).bottom, anchors(b).top));
        paths[i].classList.toggle("is-lit", selected === e.from || selected === e.to);
      });
    };

    const measure = () => {
      width = root.clientWidth || width;
      els.forEach((el, id) => {
        const h = el.offsetHeight;
        if (h) heights[id] = h;
      });
      render();
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(root);
    els.forEach((el) => ro.observe(el));

    /* keep a card inside the canvas */
    const moveTo = (n: LayoutNode, dx: number, dy: number) => {
      const { w } = place(n);
      const h = heights[n.id] ?? 90;
      const baseCx = n.x * width;
      const baseTop = layout.topOf(n);
      const cx = clamp(baseCx + dx, w / 2 + 8, width - w / 2 - 8);
      const top = clamp(baseTop + dy, 8, layout.canvasH - h - 8);
      offsets[n.id] = { dx: cx - baseCx, dy: top - baseTop };
      render();
    };

    /* ── dragging ── */
    let suppressClick = false;
    for (const n of nodes) {
      const el = els.get(n.id);
      if (!el) continue;
      el.addEventListener("pointerdown", (event) => {
        if (event.button !== 0 || (event.target as Element).closest("[data-ui]")) return;
        const start = { x: event.clientX, y: event.clientY, dx: offsets[n.id]?.dx ?? 0, dy: offsets[n.id]?.dy ?? 0 };
        let moved = false;
        const onMove = (e: PointerEvent) => {
          const dx = start.dx + e.clientX - start.x;
          const dy = start.dy + e.clientY - start.y;
          if (!moved && Math.hypot(e.clientX - start.x, e.clientY - start.y) < 3) return;
          moved = true;
          el.dataset.dragging = "";
          moveTo(n, dx, dy);
        };
        const onUp = () => {
          window.removeEventListener("pointermove", onMove);
          window.removeEventListener("pointerup", onUp);
          window.removeEventListener("pointercancel", onUp);
          delete el.dataset.dragging;
          if (moved) {
            /* a real drag shouldn't also toggle selection */
            suppressClick = true;
            setTimeout(() => (suppressClick = false), 0);
          }
        };
        window.addEventListener("pointermove", onMove);
        window.addEventListener("pointerup", onUp);
        window.addEventListener("pointercancel", onUp);
      });

      /* ── selection + keyboard nudge (step cards) ── */
      const button = el.querySelector<HTMLButtonElement>("button.card--step");
      if (!button) continue;
      button.addEventListener("click", () => {
        if (suppressClick) return;
        selected = selected === n.id ? null : n.id;
        root.querySelectorAll<HTMLButtonElement>("button.card--step").forEach((b) =>
          b.setAttribute("aria-pressed", String(b === button && selected === n.id)),
        );
        render();
        root.dispatchEvent(new CustomEvent("vs-flowchart-select", { bubbles: true, detail: { id: selected } }));
      });
      button.addEventListener("keydown", (event) => {
        const step = event.shiftKey ? 32 : 8;
        const delta: Record<string, [number, number]> = {
          ArrowLeft: [-step, 0],
          ArrowRight: [step, 0],
          ArrowUp: [0, -step],
          ArrowDown: [0, step],
        };
        const d = delta[event.key];
        if (!d) return;
        event.preventDefault();
        moveTo(n, (offsets[n.id]?.dx ?? 0) + d[0], (offsets[n.id]?.dy ?? 0) + d[1]);
      });
    }

    /* ── select chips (listbox pattern, gliding highlight) ── */
    let openClose: ((focusChip: boolean) => void) | null = null;

    root.querySelectorAll<HTMLElement>(".select").forEach((wrap) => {
      const chip = wrap.querySelector<HTMLButtonElement>("button.chip")!;
      const menu = wrap.querySelector<HTMLElement>('[role="listbox"]')!;
      const glide = menu.querySelector<HTMLElement>(".glide")!;
      const valueEl = chip.querySelector<HTMLElement>(".chip-value")!;
      const options = [...menu.querySelectorAll<HTMLElement>('[role="option"]')];
      let hovered: number | null = null;

      const valueIndex = () => Math.max(0, options.findIndex((o) => o.getAttribute("aria-selected") === "true"));
      const moveGlide = (instant = false) => {
        const row = options[hovered ?? valueIndex()];
        if (instant) glide.style.transition = "none";
        if (row) {
          glide.style.top = `${row.offsetTop}px`;
          glide.style.height = `${row.offsetHeight}px`;
        }
        glide.style.opacity = hovered !== null ? "1" : "0";
        if (instant) {
          void glide.offsetHeight;
          glide.style.transition = "";
        }
      };
      const close = (focusChip: boolean) => {
        menu.hidden = true;
        chip.setAttribute("aria-expanded", "false");
        hovered = null;
        if (openClose === close) openClose = null;
        if (focusChip) chip.focus();
      };
      const open = (focusIndex?: number) => {
        if (openClose && openClose !== close) openClose(false);
        openClose = close;
        menu.hidden = false;
        chip.setAttribute("aria-expanded", "true");
        hovered = focusIndex ?? null;
        moveGlide(true);
        if (focusIndex !== undefined) options[focusIndex]?.focus();
      };
      const pick = (i: number) => {
        options.forEach((o, k) => o.setAttribute("aria-selected", String(k === i)));
        const value = options[i].dataset.name ?? "";
        valueEl.textContent = value;
        chip.setAttribute("aria-label", chip.getAttribute("aria-label")!.replace(/: .*$/, `: ${value}`));
        close(true);
        root.dispatchEvent(
          new CustomEvent("vs-flowchart-change", {
            bubbles: true,
            detail: { node: wrap.dataset.nodeId, condition: Number(wrap.dataset.condition), field: wrap.dataset.field, value },
          }),
        );
      };

      chip.addEventListener("click", () => (menu.hidden ? open() : close(false)));
      chip.addEventListener("keydown", (event) => {
        if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
          event.preventDefault();
          open(event.key === "ArrowUp" ? options.length - 1 : valueIndex());
        } else if (event.key === "Escape" && !menu.hidden) close(true);
      });
      menu.addEventListener("mouseleave", () => {
        hovered = null;
        moveGlide();
      });
      options.forEach((option, i) => {
        option.addEventListener("mouseenter", () => {
          hovered = i;
          moveGlide();
        });
        option.addEventListener("focus", () => {
          hovered = i;
          moveGlide();
        });
        option.addEventListener("click", () => pick(i));
        option.addEventListener("keydown", (event) => {
          const last = options.length - 1;
          const to: Record<string, number> = { ArrowDown: Math.min(i + 1, last), ArrowUp: Math.max(i - 1, 0), Home: 0, End: last };
          if (event.key in to) {
            event.preventDefault();
            options[to[event.key]].focus();
          } else if (event.key === "Enter" || event.key === " ") {
            event.preventDefault();
            pick(i);
          } else if (event.key === "Escape") {
            event.preventDefault();
            close(true);
          } else if (event.key === "Tab") close(false);
        });
      });
    });

    /* click anywhere else closes the open menu */
    const onDocDown = (event: PointerEvent) => {
      if (openClose && !(event.target as Element).closest(".select[data-ui]")) openClose(false);
    };
    document.addEventListener("pointerdown", onDocDown);
    document.addEventListener(
      "astro:before-swap",
      () => {
        ro.disconnect();
        document.removeEventListener("pointerdown", onDocDown);
      },
      { once: true },
    );
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-flowchart:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .vs-flowchart {
    position: relative;
    width: 100%;
    overflow: hidden;
    border-radius: var(--vs-radius-card);
    background-color: var(--vs-page);
    background-image: radial-gradient(var(--vs-line-strong) 1px, transparent 1.25px);
    background-size: 22px 22px;
    background-position: center;
    box-shadow: var(--vs-shadow-hairline);
    user-select: none;
  }

  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }

  /* connectors */
  .edges {
    position: absolute;
    inset: 0;
    pointer-events: none;
    overflow: visible;
  }
  .edge {
    fill: none;
    stroke: var(--vs-line-strong);
    stroke-width: 1.25;
    transition: stroke 150ms;
  }
  .edge.is-lit {
    stroke: var(--vs-accent);
  }

  /* nodes */
  .node {
    position: absolute;
    z-index: 1;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 6px;
    transform: translateX(-50%);
    touch-action: none;
  }
  .node[data-dragging] {
    z-index: 2;
    cursor: grabbing;
  }
  .node:has(.menu:not([hidden])) {
    z-index: 3;
  }
  .kind {
    display: inline-flex;
    align-items: center;
    height: 24px;
    padding: 0 8px;
    border-radius: 6px;
    background: color-mix(in srgb, var(--hue) 14%, var(--vs-page));
    color: color-mix(in srgb, var(--hue) 80%, var(--vs-ink));
    font-size: 11.5px;
    font-weight: 500;
  }

  .card {
    width: 100%;
    border-radius: 18px;
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
    transition: box-shadow 150ms;
  }
  .card:hover {
    box-shadow: var(--vs-shadow-raised);
  }

  /* step card */
  .card--step {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px;
    border: none;
    color: inherit;
    font: inherit;
    text-align: left;
    cursor: pointer;
  }
  .card--step[aria-pressed="true"] {
    box-shadow:
      0 0 0 1.5px var(--vs-accent),
      0 2px 10px rgba(0, 0, 0, 0.045);
  }
  .card--step:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .step-icon {
    display: flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    border-radius: 8px;
    background: color-mix(in srgb, var(--hue) 12%, var(--vs-surface));
    color: var(--hue);
    box-shadow: 0 0 0 1px color-mix(in srgb, var(--hue) 20%, var(--vs-surface));
  }
  .step-text {
    min-width: 0;
  }
  .step-title {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 13px;
    font-weight: 600;
    line-height: 1.25;
    color: var(--vs-ink);
  }
  .step-caption {
    display: block;
    margin-top: 2px;
    font-size: 12px;
    line-height: 1.375;
    color: var(--vs-ink-2);
  }

  /* condition card */
  .conditions {
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 10px 12px;
  }
  .cond-row {
    display: flex;
    min-width: 0;
    align-items: center;
    gap: 6px;
  }
  .cond-row--wrap {
    flex-wrap: wrap;
  }
  .wrap-value {
    max-width: 100%;
    padding-left: 49px;
  }
  .handle {
    flex-shrink: 0;
    cursor: grab;
    color: color-mix(in srgb, var(--vs-ink-3) 70%, transparent);
  }
  .join {
    width: 28px;
    font-size: 12.5px;
    color: var(--vs-ink-2);
  }
  .join-op {
    font-size: 12.5px;
    color: var(--vs-ink-2);
  }
  .source {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    gap: 4px;
    height: 24px;
    padding: 0 6px;
    border-radius: 6px;
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-btn);
    font-size: 12px;
    font-weight: 500;
    color: var(--vs-ink);
  }
  .source svg {
    color: var(--vs-ink-2);
  }

  /* select chip + menu */
  .select {
    position: relative;
    display: inline-flex;
    min-width: 0;
  }
  .chip {
    display: inline-flex;
    min-width: 0;
    align-items: center;
    gap: 4px;
    height: 24px;
    padding: 0 6px;
    border: none;
    border-radius: 6px;
    background: var(--vs-field);
    color: var(--vs-ink);
    font: inherit;
    font-size: 12px;
    font-weight: 500;
    cursor: pointer;
    transition: background-color 100ms;
  }
  .chip:hover,
  .chip[aria-expanded="true"] {
    background: var(--vs-hover-2);
  }
  .chip:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .chip-value {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .chevron {
    flex-shrink: 0;
    color: var(--vs-ink-3);
  }
  .dot {
    flex-shrink: 0;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--hue);
  }

  .menu {
    position: absolute;
    bottom: 100%;
    z-index: 20;
    margin-bottom: 6px;
    padding: 4px;
    border-radius: 10px;
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-raised);
    animation: vs-pop-in 180ms var(--vs-ease-out) both;
  }
  .menu--left {
    left: 0;
    transform-origin: bottom left;
  }
  .menu--right {
    right: 0;
    transform-origin: bottom right;
  }
  .glide {
    position: absolute;
    left: 4px;
    right: 4px;
    top: 0;
    height: 0;
    border-radius: 6px;
    background: var(--vs-hover);
    opacity: 0;
    pointer-events: none;
    transition:
      top 220ms var(--vs-ease-out),
      height 220ms var(--vs-ease-out),
      opacity 150ms ease;
  }
  .option {
    position: relative;
    z-index: 1;
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    height: 30px;
    box-sizing: border-box;
    padding: 0 8px;
    border-radius: 6px;
    cursor: pointer;
    outline: none;
  }
  .option:focus-visible {
    box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--vs-accent) 70%, transparent);
  }
  .option-name {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 12.5px;
    font-weight: 500;
    color: var(--vs-ink);
  }
  .option-tag {
    flex-shrink: 0;
    font-size: 11px;
    color: var(--vs-ink-3);
  }
  .check {
    flex-shrink: 0;
    color: var(--vs-ink);
    visibility: hidden;
  }
  .option[aria-selected="true"] .check {
    visibility: visible;
  }

  @media (prefers-reduced-motion: reduce) {
    .menu {
      animation: none;
    }
    .glide,
    .edge,
    .card {
      transition: none;
    }
  }
</style>
```

### `src/components/viewership/flowchart/layout.ts`

```ts
/**
 * Flowchart layout math, shared by the server render (estimated heights)
 * and the client script (measured heights, live width, drag offsets).
 * Adapted from Beautiful UI Flowchart (MIT, © 2026 Shane Levine).
 */

const PAD_Y = 24;
const ROW_GAP = 64;
/** Kind pill + gap above a card. */
export const PILL_OFFSET = 30;

export type LayoutNode = { id: string; row: number; x: number; w: number; kind: boolean };
export type Point = { x: number; y: number };

/** Row tops and total canvas height from per-node heights. */
export function rowLayout(nodes: LayoutNode[], heights: Record<string, number>) {
  const rows = [...new Set(nodes.map((n) => n.row))].sort((a, b) => a - b);
  const rowH = rows.map((r) => Math.max(...nodes.filter((n) => n.row === r).map((n) => heights[n.id] ?? 90)));
  const rowY: number[] = [];
  rows.forEach((_, i) => {
    rowY[i] = i === 0 ? PAD_Y : rowY[i - 1] + rowH[i - 1] + ROW_GAP;
  });
  const canvasH = rows.length ? rowY[rows.length - 1] + rowH[rows.length - 1] + PAD_Y : PAD_Y * 2;
  const topOf = (n: LayoutNode) => rowY[rows.indexOf(n.row)];
  return { canvasH, topOf };
}

/** The connector between two anchors: a clamped vertical bezier. */
export function bezier(from: Point, to: Point) {
  const k = Math.min(Math.max(Math.abs(to.y - from.y) * 0.55, 24), 84);
  return `M ${from.x} ${from.y} C ${from.x} ${from.y + k}, ${to.x} ${to.y - k}, ${to.x} ${to.y}`;
}
```
