Arrow keys move this card.
Trigger
If / Else
If request is
and request is
Workflow trigger and condition cards on a dotted canvas with draggable steps.
DataComponentNo dependencies Beautiful UI, MIT
foundation.css holds the tokens every component reads.
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
--- ---
import Flowchart from "../components/viewership/flowchart/Flowchart.astro";
---
<Flowchart /> | 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. |
---
/**
* 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> /**
* 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}`;
} Ported from Beautiful UI, MIT. Notices.