+ {MONTHS.map(({ month }, index) => (
+
{month}
))}
@@ -652,69 +885,99 @@ export function BarChartCard() {
);
}
+type Spark = [number, number][];
+
+const RISING: Spark = [
+ [0, 30],
+ [16, 26],
+ [32, 27],
+ [48, 18],
+ [64, 16],
+ [80, 10],
+ [96, 4],
+];
+const FALLING: Spark = [
+ [0, 6],
+ [16, 9],
+ [32, 12],
+ [48, 14],
+ [64, 20],
+ [80, 22],
+ [96, 30],
+];
+const BUMPY: Spark = [
+ [0, 24],
+ [16, 14],
+ [32, 22],
+ [48, 9],
+ [64, 18],
+ [80, 8],
+ [96, 12],
+];
+
+/** One set of figures for each period the panel can be asked about. */
+const PERIODS = [
+ {
+ label: 'Last 7 days',
+ rows: [
+ { value: '$74,180', delta: '+2.1%', spark: BUMPY },
+ { value: '39.2%', delta: '−1.6%', spark: BUMPY },
+ { value: '612', delta: '+0.9%', spark: RISING },
+ ],
+ },
+ {
+ label: 'Last 30 days',
+ rows: [
+ { value: '$317,904', delta: '+7.8%', spark: RISING },
+ { value: '37.6%', delta: '−8.4%', spark: FALLING },
+ { value: '2,867', delta: '+4.2%', spark: RISING },
+ ],
+ },
+ {
+ label: 'Last 90 days',
+ rows: [
+ { value: '$902,455', delta: '+18.3%', spark: RISING },
+ { value: '41.0%', delta: '−3.1%', spark: FALLING },
+ { value: '8,140', delta: '+11.6%', spark: BUMPY },
+ ],
+ },
+];
+
+const KPI_ROWS = [
+ { label: 'Total Revenue', color: 'var(--chart-1)' },
+ { label: 'Bounce Rate', color: 'var(--chart-3)' },
+ { label: 'New Customers', color: 'var(--chart-2)' },
+];
+
/**
* KPI — several metrics gathered into one panel: the number, what it did, and
* the shape it made getting there, one row each under a shared tray header.
+ * The header's action steps through the periods.
*/
export function KpiCard() {
- const rows = [
- {
- label: 'Total Revenue',
- value: '$317,904',
- delta: '+7.8%',
- caption: 'last 30d',
- color: 'var(--chart-1)',
- spark: [
- [0, 30],
- [16, 26],
- [32, 27],
- [48, 18],
- [64, 16],
- [80, 10],
- [96, 4],
- ] as [number, number][],
- },
- {
- label: 'Bounce Rate',
- value: '37.6%',
- delta: '−8.4%',
- caption: 'vs last 7d',
- color: 'var(--chart-3)',
- spark: [
- [0, 6],
- [16, 9],
- [32, 12],
- [48, 14],
- [64, 20],
- [80, 22],
- [96, 30],
- ] as [number, number][],
- },
- {
- label: 'New Customers',
- value: '2,867',
- delta: '+4.2%',
- caption: 'this week',
- color: 'var(--chart-2)',
- spark: [
- [0, 30],
- [16, 27],
- [32, 24],
- [48, 18],
- [64, 17],
- [80, 11],
- [96, 5],
- ] as [number, number][],
- },
- ];
+ const [period, setPeriod] = useState(1);
+ const { label, rows } = PERIODS[period];
return (
-
+
setPeriod((period + 1) % PERIODS.length)}
+ aria-label={`${label}. Show the next period`}
+ className={cn('-me-1.5 flex items-center gap-1 rounded-md px-1.5 py-0.5 hover:bg-accent hover:text-foreground', PRESS, FOCUS)}
+ >
+ {label}
+
+
+ }
+ />
- {rows.map(({ label, value, delta, caption, color, spark }, index) => (
+ {KPI_ROWS.map(({ label: name, color }, index) => (
- {label}
- {value}
+ {name}
+ {rows[index].value}
- {delta} {' '}
- {caption}
+ {rows[index].delta} {' '}
+ vs the one before
@@ -749,27 +1013,68 @@ export function KpiCard() {
/** A form: labelled fields, then the two buttons that close it. */
export function FormCard() {
+ const [name, setName] = useState('');
+ const [target, setTarget] = useState('$15,000');
+ const [date, setDate] = useState('Dec 2026');
+ const [state, setState] = useState<'idle' | 'missing' | 'made'>('idle');
+
+ const create = () => {
+ if (!name.trim()) return setState('missing');
+ setState('made');
+ };
+ const clear = () => {
+ setName('');
+ setTarget('$15,000');
+ setDate('Dec 2026');
+ setState('idle');
+ };
+
return (
Goal name
-
+ {
+ setName(next);
+ if (state !== 'idle') setState('idle');
+ }}
+ onEnter={create}
+ invalid={state === 'missing'}
+ size="sm"
+ />
+ {state === 'missing' ? (
+
+ Give the goal a name first.
+
+ ) : null}
- Create goal
-
+
+ {state === 'made' ? (
+ <>
+
+ Goal created. Make another
+ >
+ ) : (
+ 'Create goal'
+ )}
+
+
Cancel
@@ -778,19 +1083,74 @@ export function FormCard() {
);
}
-/** Slider and Progress — the two bars, with their captions. */
+/** Slider and Progress — the two bars, with their captions. The slider drags. */
export function SlidersCard() {
+ const MAX = 5000;
+ const STEP = 100;
+ const [payout, setPayout] = useState(2500);
+ const share = (payout / MAX) * 100;
+
+ const drag = (event: ReactPointerEvent) => {
+ const box = event.currentTarget.getBoundingClientRect();
+ const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
+ const along = (event.clientX - box.left) / box.width;
+ const at = Math.min(1, Math.max(0, rtl ? 1 - along : along));
+ setPayout(Math.round((at * MAX) / STEP) * STEP);
+ };
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ const move: Record = {
+ ArrowRight: STEP,
+ ArrowUp: STEP,
+ ArrowLeft: -STEP,
+ ArrowDown: -STEP,
+ PageUp: STEP * 5,
+ PageDown: -STEP * 5,
+ };
+ if (event.key === 'Home') setPayout(0);
+ else if (event.key === 'End') setPayout(MAX);
+ else if (move[event.key]) setPayout(Math.min(MAX, Math.max(0, payout + move[event.key])));
+ else return;
+ event.preventDefault();
+ };
+
return (
Minimum payout
- $2,500
+
+ ${payout.toLocaleString('en-US')}
+
-
-
-
-
+ {
+ event.currentTarget.setPointerCapture(event.pointerId);
+ drag(event);
+ }}
+ onPointerMove={(event) => {
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) drag(event);
+ }}
+ className={cn(
+ 'relative flex h-5 w-full cursor-grab touch-none flex-row items-center rounded-full bg-muted active:cursor-grabbing',
+ FOCUS
+ )}
+ >
+ {/* The fill reaches to the thumb's far edge, so the thumb is the
+ end of the fill and never floats past it. */}
+
+
@@ -820,16 +1180,38 @@ export function SlidersCard() {
/** Alert — the soft status fill, over the badges that use the subtle one. */
export function AlertCard() {
+ const [updated, setUpdated] = useState(false);
+
return (
-
-
+
+ {updated ? (
+
+ ) : (
+
+ )}
- Update available
-
- 0.57.0 adds a QR code and a components index.
+
+ {updated ? 'You are up to date' : 'Update available'}
+
+
+ {updated ? 'Installed a moment ago.' : 'A newer version is ready to install.'}
+
setUpdated(!updated)}>
+ {updated ? 'Undo' : 'Update'}
+
Info
@@ -841,8 +1223,43 @@ export function AlertCard() {
);
}
-/** A chat transcript, and the composer under it. */
+interface Line {
+ id: number;
+ from: 'them' | 'me';
+ text: string;
+}
+
+const REPLIES = [
+ 'It follows the system setting until you choose.',
+ 'Yes. Every component reads the same tokens.',
+ 'No native code, so it runs in Expo Go.',
+];
+
+/** A chat transcript, and the composer under it. A message can be sent. */
export function ChatCard() {
+ const [lines, setLines] = useState
([
+ { id: 1, from: 'them', text: 'How do I switch the theme at runtime?' },
+ { id: 2, from: 'me', text: 'Call setTheme from useThemeMode.' },
+ ]);
+ const [draft, setDraft] = useState('');
+ const sent = useRef(0);
+ const timer = useRef(0);
+
+ useEffect(() => () => window.clearTimeout(timer.current), []);
+
+ const send = () => {
+ const text = draft.trim();
+ if (!text) return;
+ const reply = REPLIES[sent.current % REPLIES.length];
+ sent.current += 1;
+ // Only the last few are kept, so the card stays the height it is.
+ setLines((current) => [...current, { id: Date.now(), from: 'me' as const, text }].slice(-4));
+ setDraft('');
+ timer.current = window.setTimeout(() => {
+ setLines((current) => [...current, { id: Date.now(), from: 'them' as const, text: reply }].slice(-4));
+ }, 700);
+ };
+
return (
@@ -853,125 +1270,273 @@ export function ChatCard() {
-
-
-
-
-
- How do I switch the theme at runtime?
-
-
-
-
-
-
-
-
- Call setTheme from useThemeMode.
-
+
+ {lines.map(({ id, from, text }, index) => (
+
+
+
+
+ {text}
+
+
+ {from === 'me' && index === lines.length - 1 ? (
+
Just now
+ ) : null}
-
Just now
-
+ ))}
-
- Ask anything…
-
-
-
-
+ setDraft(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') send();
+ }}
+ placeholder="Ask anything…"
+ className="h-10 min-w-0 flex-1 rounded-full border border-input bg-background px-4 text-[16px] text-foreground outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 sm:text-[14px]"
+ />
+
+
+
);
}
+const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+const MONTH_NAMES = [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December',
+];
+
/** Calendar — a month of days, with one picked and the neighbours dimmed. */
export function CalendarCard() {
- const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
- /* July 2026 starts on a Wednesday, so the grid opens on three days of June
- and closes on eight of August — both drawn, both muted. */
- const days = [
- ...[28, 29, 30].map((d) => ({ day: d, outside: true })),
- ...Array.from({ length: 31 }, (_, i) => ({ day: i + 1, outside: false })),
- ...Array.from({ length: 8 }, (_, i) => ({ day: i + 1, outside: true })),
- ];
+ /* Fixed, not today: the server and the browser have to draw the same month. */
+ const [shown, setShown] = useState({ year: 2026, month: 6 });
+ const [picked, setPicked] = useState({ year: 2026, month: 6, day: 29 });
+
+ const first = new Date(shown.year, shown.month, 1).getDay();
+ const length = new Date(shown.year, shown.month + 1, 0).getDate();
+ const before = new Date(shown.year, shown.month, 0).getDate();
+ /* Six rows always, so the card does not change height from month to month. */
+ const cells = Array.from({ length: 42 }, (_, index) => {
+ const day = index - first + 1;
+ if (day < 1) return { day: before + day, offset: -1 };
+ if (day > length) return { day: day - length, offset: 1 };
+ return { day, offset: 0 };
+ });
+
+ const turn = (by: number) => {
+ const next = new Date(shown.year, shown.month + by, 1);
+ setShown({ year: next.getFullYear(), month: next.getMonth() });
+ };
+
+ const pick = (day: number, offset: number) => {
+ const at = new Date(shown.year, shown.month + offset, day);
+ setPicked({ year: at.getFullYear(), month: at.getMonth(), day: at.getDate() });
+ if (offset !== 0) setShown({ year: at.getFullYear(), month: at.getMonth() });
+ };
+
+ const pickedDate = new Date(picked.year, picked.month, picked.day);
+ const pickedHere = picked.year === shown.year && picked.month === shown.month;
+
+ /** The arrow keys walk the grid: a day either way, or a week up or down. */
+ const walk = (event: KeyboardEvent
) => {
+ const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[event.key];
+ if (!step) return;
+ const days = [...event.currentTarget.querySelectorAll('button')];
+ const from = days.indexOf(document.activeElement as HTMLButtonElement);
+ const to = days[from + step];
+ if (from < 0 || !to) return;
+ event.preventDefault();
+ to.focus();
+ };
return (
-
-
-
- July 2026
-
-
+ turn(-1)}
+ className={cn('flex h-8 w-8 items-center justify-center rounded-lg border border-border hover:bg-accent', PRESS, FOCUS)}
+ >
+
+
+
+ {MONTH_NAMES[shown.month]} {shown.year}
+ turn(1)}
+ className={cn('flex h-8 w-8 items-center justify-center rounded-lg border border-border hover:bg-accent', PRESS, FOCUS)}
+ >
+
+
-
- {weekdays.map((weekday) => (
+
+ {WEEKDAYS.map((weekday) => (
{weekday}
))}
- {days.map(({ day, outside }, index) => (
-
-
- {day}
+ {cells.map(({ day, offset }, index) => {
+ const isPicked =
+ offset === 0 && picked.year === shown.year && picked.month === shown.month && picked.day === day;
+ return (
+
+ pick(day, offset)}
+ className={cn(
+ 'flex h-7 w-7 cursor-pointer items-center justify-center rounded-full text-sm transition-colors duration-150',
+ FOCUS,
+ isPicked
+ ? 'bg-primary font-medium text-primary-foreground'
+ : offset !== 0
+ ? 'text-muted-foreground hover:bg-accent'
+ : 'text-foreground hover:bg-accent'
+ )}
+ >
+ {day}
+
-
- ))}
+ );
+ })}
-
Wed Jul 29 2026
+
+ {WEEKDAYS[pickedDate.getDay()]} {MONTH_NAMES[picked.month].slice(0, 3)} {picked.day} {picked.year}
+
);
}
+const TABS = ['Overview', 'Activity', 'Files'] as const;
+
/** Tabs — the segmented variant: a raised chip travelling in a recessed track. */
export function TabsCard() {
- const tabs = ['Overview', 'Activity', 'Files'];
+ const [tab, setTab] = useState(0);
+ const [published, setPublished] = useState(false);
+ const refs = useRef<(HTMLButtonElement | null)[]>([]);
return (
-
- {tabs.map((tab, index) => (
-
+ {/* The chip is one element that slides, not three that take turns. */}
+
+ {TABS.map((name, index) => (
+ {
+ refs.current[index] = node;
+ }}
+ type="button"
+ role="tab"
+ aria-selected={tab === index}
+ tabIndex={tab === index ? 0 : -1}
+ onClick={() => setTab(index)}
+ onKeyDown={(event) => {
+ const next = radioIndexForKey(event.key, index, TABS.length);
+ if (next === undefined) return;
+ event.preventDefault();
+ setTab(next);
+ refs.current[next]?.focus();
+ }}
className={cn(
- 'flex flex-1 items-center justify-center rounded-md py-1.5 text-sm',
- index === 0 ? 'bg-popover text-foreground shadow-sm' : 'text-muted-foreground'
+ 'relative flex flex-1 cursor-pointer items-center justify-center rounded-md py-1.5 text-sm transition-colors duration-200',
+ FOCUS,
+ tab === index ? 'text-foreground' : 'text-foreground/75 hover:text-foreground'
)}
>
- {tab}
-
+ {name}
+
))}
-
-
- {['KA', 'ML', 'JR'].map((initials, index) => (
-
0 ? '-ms-2' : undefined} />
- ))}
-
-
and 12 others
+
+
+ {tab === 0 ? (
+ <>
+
+ {['KA', 'ML', 'JR'].map((initials, index) => (
+
0 ? '-ms-2' : undefined} />
+ ))}
+
+
and 12 others
+ >
+ ) : tab === 1 ? (
+ <>
+
+
Mina left 3 comments today
+ >
+ ) : (
+ <>
+
+
+
+
14 files, 38 MB in all
+ >
+ )}
+
Share
-
- Publish
+ setPublished(!published)}>
+ {published ? (
+ <>
+
+ Published
+ >
+ ) : (
+ 'Publish'
+ )}
@@ -980,21 +1545,27 @@ export function TabsCard() {
/** Timeline — the rail of discs, with a connector running between them. */
export function TimelineCard() {
+ const [merged, setMerged] = useState(false);
const events = [
{ title: 'Opened the pull request', meta: '2h ago', done: true },
{ title: 'Review requested', meta: '1h ago', done: true },
{ title: 'Checks passed', meta: '24m ago', done: true },
- { title: 'Merge', meta: 'Waiting', done: false },
+ { title: merged ? 'Merged' : 'Merge', meta: merged ? 'Just now' : 'Waiting', done: merged },
];
return (
- Release 0.57.0
+ Pull request #218
{events.map(({ title, meta, done }, index) => (
-
+
-
+
{index < events.length - 1 ? (
) : null}
@@ -1004,43 +1575,557 @@ export function TimelineCard() {
{title}
{meta}
+ {index === events.length - 1 ? (
+
setMerged(!merged)}>
+ {merged ? 'Revert' : 'Merge'}
+
+ ) : null}
))}
);
}
-/** Table — the header row, four body rows and a numeric column. */
+const TABLE_ROWS = [
+ { name: 'Button', state: 'Stable', adds: 12400 },
+ { name: 'BottomSheet', state: 'Stable', adds: 9100 },
+ { name: 'ColorPicker', state: 'Stable', adds: 4800 },
+ { name: 'Flow', state: 'Alpha', adds: 1200 },
+];
+
+/** Table — the header row, four body rows and a numeric column that sorts. */
export function TableCard() {
- const rows = [
- ['Button', 'Stable', '12.4k'],
- ['BottomSheet', 'Stable', '9.1k'],
- ['ColorPicker', 'Stable', '4.8k'],
- ['Flow', 'Alpha', '1.2k'],
- ];
+ const [descending, setDescending] = useState(true);
+ const [picked, setPicked] = useState
(null);
+ const rows = [...TABLE_ROWS].sort((a, b) => (descending ? b.adds - a.adds : a.adds - b.adds));
return (
-
-
-
Component
-
State
-
Adds
+
+
+
+ Component
+
+
+ State
+
+
+ setDescending(!descending)}
+ className={cn('-me-1 flex items-center gap-0.5 rounded-sm px-1 text-xs font-medium text-muted-foreground hover:text-foreground', PRESS, FOCUS)}
+ >
+ Adds
+
+
+
- {rows.map(([name, state, adds]) => (
+ {rows.map(({ name, state, adds }) => (
setPicked(picked === name ? null : name)}
+ onKeyDown={(event) => {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ event.preventDefault();
+ setPicked(picked === name ? null : name);
+ }}
+ className={cn(
+ 'flex cursor-pointer flex-row items-center gap-3 border-b border-border px-5 py-2.5 outline-none transition-colors duration-150 last:border-b-0 hover:bg-accent focus-visible:bg-accent',
+ picked === name && 'bg-accent'
+ )}
>
- {name}
-
+
+ {name}
+
+
{state}
- {adds}
+
+ {(adds / 1000).toFixed(1)}k
+
+
+ ))}
+
+
+ );
+}
+
+/**
+ * OTP input — one cell a digit, with a caret in the cell that is next. The
+ * cells are a drawing over one real field, so a code can be typed or pasted
+ * in one go and the keyboard on a phone is the numeric one.
+ */
+export function OtpCard() {
+ const LENGTH = 6;
+ const [code, setCode] = useState('482');
+ const [focused, setFocused] = useState(false);
+ const done = code.length === LENGTH;
+
+ return (
+
+
+
+
+
+ {Array.from({ length: LENGTH }, (_, index) => (
+
+ {index === LENGTH / 2 ? : null}
+
+ {code[index] ? (
+ {code[index]}
+ ) : focused && index === code.length ? (
+
+ ) : null}
+
+
+ ))}
+
setCode(event.target.value.replace(/\D/g, '').slice(0, LENGTH))}
+ onFocus={() => setFocused(true)}
+ onBlur={() => setFocused(false)}
+ // Over the cells and see-through: it takes the taps and the typing.
+ className="absolute inset-0 cursor-text opacity-0"
+ />
+
+
+
+ {done ? 'Verified' : `${LENGTH - code.length} to go`}
+
+ setCode('')}>
+ {done ? 'Start over' : 'Clear'}
+
+
+
+
+ );
+}
+
+const PLANS = [
+ { id: 'hobby', name: 'Hobby', price: 'Free', detail: 'One project, community support' },
+ { id: 'pro', name: 'Pro', price: '$12', detail: 'Unlimited projects, private themes' },
+ { id: 'team', name: 'Team', price: '$29', detail: 'Shared libraries and review' },
+];
+
+/** Radio group, in its card variant — one of a few, each with room to explain itself. */
+export function PlanCard() {
+ const [plan, setPlan] = useState(1);
+ const refs = useRef<(HTMLButtonElement | null)[]>([]);
+
+ return (
+
+ Choose a plan
+
+ {PLANS.map(({ id, name, price, detail }, index) => (
+ {
+ refs.current[index] = node;
+ }}
+ type="button"
+ role="radio"
+ aria-checked={plan === index}
+ tabIndex={plan === index ? 0 : -1}
+ onClick={() => setPlan(index)}
+ onKeyDown={(event) => {
+ const next = radioIndexForKey(event.key, index, PLANS.length);
+ if (next === undefined) return;
+ event.preventDefault();
+ setPlan(next);
+ refs.current[next]?.focus();
+ }}
+ className={cn(
+ 'flex w-full cursor-pointer flex-row items-start justify-between gap-3 rounded-xl border p-4 text-start transition-colors duration-150',
+ FOCUS,
+ plan === index ? 'border-primary bg-accent' : 'border-border bg-card hover:bg-accent'
+ )}
+ >
+
+
+ {name}
+ {price}
+
+ {detail}
+
+
+
+
+
))}
+ Continue with {PLANS[plan].name}
+
+ );
+}
+
+const QUESTIONS = [
+ {
+ question: 'Does it run in Expo Go?',
+ answer: 'Yes. There is no native code in it, so there is nothing to build first.',
+ },
+ {
+ question: 'Can I change the theme?',
+ answer: 'It is one setting. Every component reads the same tokens, light and dark.',
+ },
+ {
+ question: 'Is it accessible?',
+ answer: 'Roles, states and labels are wired into every component.',
+ },
+];
+
+/** Accordion — one answer open at a time, and the rest a line each. */
+export function AccordionCard() {
+ const [open, setOpen] = useState
(0);
+ const id = useId();
+
+ return (
+
+ {QUESTIONS.map(({ question, answer }, index) => (
+
+ {index > 0 ?
: null}
+
setOpen(open === index ? null : index)}
+ className={cn(
+ 'flex w-full cursor-pointer flex-row items-center gap-3 px-4 py-3.5 text-start outline-none transition-colors hover:bg-accent focus-visible:bg-accent'
+ )}
+ >
+ {question}
+
+
+ {/* A grid row that goes from nothing to its content's height, which
+ is how a height can be animated without knowing it. */}
+
+
+ ))}
+
+ );
+}
+
+const SETTINGS = [
+ { id: 'push', icon: BellIcon, title: 'Push notifications', detail: 'Mentions and replies' },
+ { id: 'digest', icon: MailIcon, title: 'Weekly digest', detail: 'Every Monday morning' },
+ { id: 'sounds', icon: Volume2Icon, title: 'Sounds', detail: 'When a message arrives' },
+];
+
+/** Item — a row with a medium, a title, a description and an action at the end. */
+export function SettingsCard() {
+ const [on, setOn] = useState>({ push: true, digest: false, sounds: true });
+
+ return (
+
+
+ Notifications
+
+ {SETTINGS.map(({ id, icon: Icon, title, detail }) => (
+
+
+
+
+
+ {title}
+ {detail}
+
+ setOn({ ...on, [id]: next })} />
+
+ ))}
+
+ );
+}
+
+const FORMATS = [
+ { id: 'bold', icon: BoldIcon, label: 'Bold' },
+ { id: 'italic', icon: ItalicIcon, label: 'Italic' },
+ { id: 'underline', icon: UnderlineIcon, label: 'Underline' },
+];
+
+/** Rating, number input and toggle buttons: three small controls that hold a value. */
+export function ReviewCard() {
+ const [stars, setStars] = useState(4);
+ const [preview, setPreview] = useState(null);
+ const [seats, setSeats] = useState(3);
+ const [formats, setFormats] = useState>({ bold: true });
+ const lit = preview ?? stars;
+
+ return (
+
+
+
+ Rate this release
+ {stars} of 5
+
+
setPreview(null)}
+ >
+ {[1, 2, 3, 4, 5].map((star) => (
+ setStars(star)}
+ onPointerEnter={() => setPreview(star)}
+ onKeyDown={(event) => {
+ const next = radioIndexForKey(event.key, stars - 1, 5);
+ if (next === undefined) return;
+ event.preventDefault();
+ setStars(next + 1);
+ (event.currentTarget.parentElement?.children[next] as HTMLElement | undefined)?.focus();
+ }}
+ className={cn('rounded-md', PRESS, FOCUS)}
+ >
+
+
+ ))}
+
+
+
+
+
Seats
+
+
setSeats(seats - 1)}
+ className={cn('flex h-full w-10 items-center justify-center hover:bg-accent disabled:pointer-events-none disabled:opacity-[0.4]', PRESS, 'outline-none focus-visible:bg-accent')}
+ >
+
+
+
+ {seats}
+
+
= 20}
+ onClick={() => setSeats(seats + 1)}
+ className={cn('flex h-full w-10 items-center justify-center hover:bg-accent disabled:pointer-events-none disabled:opacity-[0.4]', PRESS, 'outline-none focus-visible:bg-accent')}
+ >
+
+
+
+
+
+
+ {FORMATS.map(({ id, icon: Icon, label }) => (
+ setFormats({ ...formats, [id]: !formats[id] })}
+ className={cn(
+ 'flex h-9 w-9 items-center justify-center rounded-lg border',
+ PRESS,
+ FOCUS,
+ formats[id] ? 'border-primary/24 bg-accent' : 'border-transparent bg-secondary'
+ )}
+ >
+
+
+ ))}
+
+ Sample text
+
+
+
+ );
+}
+
+const STEPS = ['Cart', 'Shipping', 'Payment', 'Done'];
+const STEP_NOTES = [
+ 'Three items, $148.00',
+ 'Standard, arrives Thursday',
+ 'Card ending in 4242',
+ 'Order placed. Thank you.',
+];
+
+/** Steps — where somebody is in a short run of stages, and what is left. */
+export function StepsCard() {
+ const [at, setAt] = useState(1);
+
+ return (
+
+
+ {STEPS.map((name, index) => (
+
+
+ {index < at ? (
+
+ ) : (
+
+ {index + 1}
+
+ )}
+
+ {index < STEPS.length - 1 ? (
+
+ ) : null}
+
+ ))}
+
+
+ {STEPS[at]}
+ {STEP_NOTES[at]}
+
+
+ setAt(at - 1)}>
+ Back
+
+ setAt(at === STEPS.length - 1 ? 0 : at + 1)}
+ >
+ {at === STEPS.length - 1 ? 'Start again' : 'Next'}
+
+
+
+ );
+}
+
+const USAGE = [
+ { id: 'photos', label: 'Photos', track: 'bg-info/16', fill: 'bg-info', full: 48, lean: 21 },
+ { id: 'files', label: 'Documents', track: 'bg-warning/16', fill: 'bg-warning', full: 22, lean: 18 },
+ { id: 'apps', label: 'Apps', track: 'bg-success/16', fill: 'bg-success', full: 9, lean: 9 },
+];
+
+/** Meter — how much of something is used, one bar to a kind, in its own colour. */
+export function UsageCard() {
+ const [cleaned, setCleaned] = useState(false);
+ const used = USAGE.reduce((sum, row) => sum + (cleaned ? row.lean : row.full), 0);
+
+ return (
+
+
+
+ Storage
+ {used}%
+
+
setCleaned(!cleaned)}>
+ {cleaned ? 'Undo' : 'Clean up'}
+
+
+ {USAGE.map(({ id, label, track, fill, full, lean }) => {
+ const value = cleaned ? lean : full;
+ return (
+
+
+ {label}
+ {value}%
+
+
+
+ );
+ })}
);
}
diff --git a/apps/docs/components/showcase/index.tsx b/apps/docs/components/showcase/index.tsx
index f10f79fa..d855dbb7 100644
--- a/apps/docs/components/showcase/index.tsx
+++ b/apps/docs/components/showcase/index.tsx
@@ -2,107 +2,146 @@ import Link from 'next/link';
import { ArrowRightIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
+ AccordionCard,
AlertCard,
AreaChartCard,
+ AttachmentCard,
BarChartCard,
+ CalendarCard,
ChatCard,
ControlsCard,
FormCard,
- CalendarCard,
- AttachmentCard,
KpiCard,
+ OtpCard,
+ PlanCard,
+ ReviewCard,
+ SettingsCard,
SlidersCard,
+ StepsCard,
TableCard,
TabsCard,
TimelineCard,
+ UsageCard,
} from './cards';
import { Themer } from './themer';
/**
- * The home page's second screen: what the components look like, and what
- * changes when the theme does.
+ * The home page's second screen: what the components look like, what they are
+ * like to use, and what changes when the theme does.
*
- * It replaced a list of every component's name. A name tells a visitor there
- * is a Slider; it does not tell them whether they want this one. The grid
- * fades out at the bottom rather than ending, because it is a sample and the
- * page carries on — the full list lives at /docs/components, linked below it.
+ * It is a wall, the full width of the window. Five columns of cards drift
+ * slowly past, each a different length and each going its own way, so the
+ * section is never showing the same thing twice in a row. The moment the
+ * pointer comes onto the wall, or a key reaches it, everything holds still:
+ * the cards are there to be pressed, and nobody can press something that is
+ * moving away from them.
*
- * Four columns of stacked cards rather than one grid of equal cells: the
- * previews are different heights by nature, and a grid would either crop them
- * or leave holes.
+ * Columns of stacked cards rather than one grid of equal cells: the previews
+ * are different heights by nature, and a grid would either crop them or leave
+ * holes.
*/
+
+/** One column of the wall. */
+function Column({ className, children }: { className?: string; children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
export function Showcase(): React.ReactElement {
return (
-
- {/* Wider than the prose sections below it. Four columns of previews at
- the page's reading width leaves each one narrower than a phone, and
- every label in them wraps or truncates. */}
-
-
+
+
+
One import, and it already looks like this
-
- Every component reads the same tokens, so a theme is one setting rather than a
- hundred overrides. Switch families below — the corners move too, because a theme
- is a shape as well as a palette.
+
+ Go on, press something. Then switch the theme: every component reads the same
+ tokens, so the whole wall changes at once, corners included.
+ {/* Past this, a keyboard is in among sixty controls that are only a
+ demonstration. One link gets it out again. */}
+
+ Skip the component previews
+
{/*
- * `zoom`, because these are phone components on a desktop page.
- * Every size in them is the size it is on a device — an 18px card
- * title, a 48px field — and at full scale in a 300px column that
- * reads as a phone screenshot blown up, with labels wrapping and
- * truncating. Zoom shrinks the drawing and *widens* the layout in
- * the same move: the column keeps its pixels but gets more logical
- * room, so nothing has to be restyled to fit. A transform would
- * scale the picture and leave the layout alone, which is the half
- * of it that does not help.
+ * Clipped to a fixed height and faded at both ends, so the columns
+ * have somewhere to drift from and to. Every column is taller than
+ * this box, which is what the drift travels over.
*
- * Browsers without it render at full size, which is what this looked
- * like before.
+ * `--wall` and `--zoom` are read by the drift in `global.css`: a
+ * column travels its own height less the wall's, and the wall's
+ * height has to be said in the column's own, zoomed units.
*/}
- {/*
- * Clipped to a fixed height, not left to end where the columns
- * happen to end. Four stacks of different lengths finish at four
- * different places, which reads as three holes rather than as a
- * sample — and the fade has nothing to fade if the shortest column
- * ran out well above it. Every column is longer than this box, so
- * all four are cut on the same line and the gradient does the rest.
- */}
-
-
-
+
+ {/*
+ * `zoom`, because these are phone components on a desktop page.
+ * Every size in them is the size it is on a device — an 18px card
+ * title, a 48px field — and at full scale in a narrow column that
+ * reads as a phone screenshot blown up. Zoom shrinks the drawing
+ * and *widens* the layout in the same move, so nothing has to be
+ * restyled to fit. On a phone they are shown at the size they are.
+ *
+ * Below `lg` the columns do not fit side by side, so the row
+ * scrolls sideways under a finger and stops on a column.
+ */}
+
-
-
-
+
+ {/* Room for a fifth only on a wide screen. Below `lg` it is one
+ more column to swipe to. */}
+
+
+
+
+
+
-
}>
- Browse every component
-
-
+
+
}>
+ Browse every component
+
+
+
);
diff --git a/apps/docs/components/showcase/themer.tsx b/apps/docs/components/showcase/themer.tsx
index 44a100f4..29403e1a 100644
--- a/apps/docs/components/showcase/themer.tsx
+++ b/apps/docs/components/showcase/themer.tsx
@@ -22,8 +22,8 @@ import { radioIndexForKey } from '../composite-keyboard';
* this one is the site's: it drives the same state the toggle in the
* navigation does, and the previews follow it.
*
- * Only the picker is interactive, so only the picker is a client component —
- * the previews are passed in as `children` and stay server-rendered.
+ * The picker sits in the page's column; the previews it themes run the full
+ * width of the window under it.
*/
/**
@@ -83,7 +83,7 @@ export function Themer({ children }: { children: ReactNode }): React.ReactElemen
return (
-
+
{children}
diff --git a/apps/docs/components/web-component-gallery.tsx b/apps/docs/components/web-component-gallery.tsx
new file mode 100644
index 00000000..1ff2b8e8
--- /dev/null
+++ b/apps/docs/components/web-component-gallery.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import Link from 'next/link';
+import { Suspense } from 'react';
+import meta from '@/scripts/web-meta.json';
+import { SECTIONS } from '@/components/gallery-sections';
+import { WEB_DEMOS } from './web-demos/index.generated';
+
+/**
+ * The web collection's index: every component as a card with the component
+ * itself running in it, and a line under it saying what it is for.
+ *
+ * The app's index draws a silhouette of each component, because a React Native
+ * component cannot run on a web page. These can, so the card shows the real
+ * thing: the globe is turning and the number is rolling before anybody has
+ * read a name.
+ *
+ * What runs in a card is the component's `thumb` demo, a small one written for
+ * this size. A page's lead demo is laid out for a wide frame and would be a
+ * corner of itself here.
+ */
+
+type MetaOptions = { category?: string; title?: string };
+type MetaEntry = [string, string, string, MetaOptions?];
+
+const entries = Object.entries(meta as unknown as Record
);
+
+const titleOf = (entry: MetaEntry) => entry[3]?.title ?? entry[0];
+
+function ComponentCard({ slug, entry }: { slug: string; entry: MetaEntry }) {
+ const Thumb = WEB_DEMOS[`${slug}/thumb`];
+
+ return (
+
+ {/*
+ The whole card is one link, so the component in it is a moving picture
+ and not a control: it takes no pointer, no focus and is not read out.
+ A slider that answered a drag here would be a slider that stopped the
+ reader getting to its page.
+ */}
+
+ {Thumb ? (
+
+
+
+ ) : null}
+
+
+
+
+ {/* The link's own box is only the name, stretched over the card,
+ so the heading keeps its semantics. */}
+
+ {titleOf(entry)}
+
+
+
+ {entry[1]}
+
+
+
+ );
+}
+
+export function WebComponentGallery() {
+ return (
+
+ {SECTIONS.map(([category, heading, lede]) => {
+ const section = entries
+ .filter(([, entry]) => (entry[3]?.category ?? 'layout') === category)
+ .sort(([, a], [, b]) => titleOf(a).localeCompare(titleOf(b)));
+ if (section.length === 0) return null;
+
+ return (
+
+
+
+ {heading}
+
+
{lede}
+
+ {/* Three across at the top width, one fewer than the app's index:
+ a running component needs more room than a silhouette of one. */}
+
+ {section.map(([slug, entry]) => (
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/docs/components/web-demos/bar-picker/coverage.tsx b/apps/docs/components/web-demos/bar-picker/coverage.tsx
new file mode 100644
index 00000000..e1d68f1f
--- /dev/null
+++ b/apps/docs/components/web-demos/bar-picker/coverage.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import { BarPicker, type BarPickerBar } from 'panelui-web/bar-picker';
+
+/** Test coverage at the end of each sprint, as a percentage. */
+const SPRINTS: BarPickerBar[] = [
+ { label: 'S1', name: 'Sprint 1', value: 52 },
+ { label: 'S2', name: 'Sprint 2', value: 58 },
+ { label: 'S3', name: 'Sprint 3', value: 57 },
+ { label: 'S4', name: 'Sprint 4', value: 66 },
+ { label: 'S5', name: 'Sprint 5', value: 74 },
+ { label: 'S6', name: 'Sprint 6', value: 81 },
+];
+
+export default function BarPickerCoverage() {
+ return (
+ `${value} percent`}
+ className="max-w-xs"
+ />
+ );
+}
diff --git a/apps/docs/components/web-demos/bar-picker/deploys.tsx b/apps/docs/components/web-demos/bar-picker/deploys.tsx
new file mode 100644
index 00000000..c9ce4422
--- /dev/null
+++ b/apps/docs/components/web-demos/bar-picker/deploys.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { useState } from 'react';
+import { BarPicker, type BarPickerBar } from 'panelui-web/bar-picker';
+
+/** Deploys to production on each day of one week. */
+const WEEK: BarPickerBar[] = [
+ { label: 'Mon', name: 'Monday', value: 9 },
+ { label: 'Tue', name: 'Tuesday', value: 14 },
+ { label: 'Wed', name: 'Wednesday', value: 11 },
+ { label: 'Thu', name: 'Thursday', value: 17 },
+ { label: 'Fri', name: 'Friday', value: 6 },
+ { label: 'Sat', name: 'Saturday', value: 0 },
+ { label: 'Sun', name: 'Sunday', value: 2 },
+];
+
+const TOTAL = WEEK.reduce((sum, day) => sum + day.value, 0);
+
+export default function BarPickerDeploys() {
+ const [day, setDay] = useState(3);
+ const picked = WEEK[day];
+ const share = Math.round((picked.value / TOTAL) * 100);
+
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Deploys
+
This week · {TOTAL}
+
+
+
+
+
+
{picked.name}
+
{picked.value}
+
+
+ {share}% of the week
+
+
+
+
`${value} deploys`}
+ />
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/bar-picker/thumb.tsx b/apps/docs/components/web-demos/bar-picker/thumb.tsx
new file mode 100644
index 00000000..5db5a67b
--- /dev/null
+++ b/apps/docs/components/web-demos/bar-picker/thumb.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import { BarPicker } from 'panelui-web/bar-picker';
+
+const DATA = [
+ { label: 'M', value: 5 },
+ { label: 'T', value: 8 },
+ { label: 'W', value: 6 },
+ { label: 'T', value: 9 },
+ { label: 'F', value: 3 },
+ { label: 'S', value: 1 },
+];
+
+export default function BarPickerThumb() {
+ return ;
+}
diff --git a/apps/docs/components/web-demos/command-dock/choose.tsx b/apps/docs/components/web-demos/command-dock/choose.tsx
new file mode 100644
index 00000000..2c879bfb
--- /dev/null
+++ b/apps/docs/components/web-demos/command-dock/choose.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import { useState } from 'react';
+import { CommandDock } from 'panelui-web/command-dock';
+
+const ACTIONS = ['Invite someone', 'Start a new notebook', 'Export this page', 'Open settings'];
+
+export default function CommandDockChoose() {
+ const [query, setQuery] = useState('');
+ const [chosen, setChosen] = useState(null);
+ const [open, setOpen] = useState(true);
+ const matches = ACTIONS.filter((action) => action.toLowerCase().includes(query.toLowerCase()));
+
+ return (
+
+
+ Do something
+
+
+
+ {matches.map((action) => (
+ {
+ setChosen(action);
+ setOpen(false);
+ }}
+ >
+ {action}
+
+ ))}
+
+
+
+
+ {chosen ? `Chosen: ${chosen}` : 'Nothing chosen yet.'}
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/command-dock/find.tsx b/apps/docs/components/web-demos/command-dock/find.tsx
new file mode 100644
index 00000000..5c514eb6
--- /dev/null
+++ b/apps/docs/components/web-demos/command-dock/find.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { FileTextIcon, SearchIcon } from 'lucide-react';
+import { useRef, useState } from 'react';
+import { CommandDock } from 'panelui-web/command-dock';
+
+/** What a search of some meeting notes might turn up. */
+const NOTES = [
+ { title: 'Pricing review', where: 'Notes from 3 March' },
+ { title: 'What we decided about trials', where: 'Notes from 19 February' },
+ { title: 'Questions for finance', where: 'Notes from 11 February' },
+ { title: 'Draft of the pricing page', where: 'Shared by Imani' },
+];
+
+export default function CommandDockFind() {
+ const [found, setFound] = useState([]);
+ const [busy, setBusy] = useState(false);
+ const timers = useRef([]);
+
+ // Stands in for a search that answers a row at a time.
+ const find = () => {
+ timers.current.forEach((timer) => window.clearTimeout(timer));
+ setFound([]);
+ setBusy(true);
+ timers.current = NOTES.map((note, index) =>
+ window.setTimeout(() => {
+ setFound((current) => [...current, note]);
+ if (index === NOTES.length - 1) setBusy(false);
+ }, 500 + index * 450)
+ );
+ };
+
+ return (
+
+
+
+ Search the notes
+
+
+
+
+ {found.map((note) => (
+
+
+ {note.title}
+ {note.where}
+
+ ))}
+ {busy ? Reading the notes : null}
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/command-dock/thumb.tsx b/apps/docs/components/web-demos/command-dock/thumb.tsx
new file mode 100644
index 00000000..c69e5339
--- /dev/null
+++ b/apps/docs/components/web-demos/command-dock/thumb.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import { FileTextIcon } from 'lucide-react';
+import { CommandDock } from 'panelui-web/command-dock';
+
+export default function CommandDockThumb() {
+ return (
+
+
+
+
+
+
+
+ Pricing review
+
+
+
+ Questions for finance
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/day-grid/colours.tsx b/apps/docs/components/web-demos/day-grid/colours.tsx
new file mode 100644
index 00000000..23fcead3
--- /dev/null
+++ b/apps/docs/components/web-demos/day-grid/colours.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+import { DayGrid, type DayCell, type DayGridColor, type DayLevel } from 'panelui-web/day-grid';
+
+/** The same fortnight in every colour, so the colour is the only difference. */
+const LEVELS: DayLevel[] = [1, 3, 4, 2, 0, 0, 2, 4, 4, 3, 1, 0, 2, 3];
+const CELLS: DayCell[] = LEVELS.map((level) => ({ level }));
+
+const COLOURS: DayGridColor[] = ['primary', 'info', 'success', 'warning', 'destructive'];
+
+export default function DayGridColours() {
+ return (
+
+ {COLOURS.map((color) => (
+
+ {color}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/docs/components/web-demos/day-grid/pick-a-day.tsx b/apps/docs/components/web-demos/day-grid/pick-a-day.tsx
new file mode 100644
index 00000000..bda5b587
--- /dev/null
+++ b/apps/docs/components/web-demos/day-grid/pick-a-day.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import { useState } from 'react';
+import { DayGrid, type DayCell, type DayLevel } from 'panelui-web/day-grid';
+
+/** Pull requests merged on each day of a four-week sprint. */
+const MERGED = [3, 5, 2, 7, 4, 0, 0, 6, 9, 4, 5, 8, 1, 0, 2, 4, 11, 6, 3, 0, 0, 7, 5, 8, 10, 6, 0, 1];
+
+const levelOf = (count: number): DayLevel =>
+ count === 0 ? 0 : count < 3 ? 1 : count < 6 ? 2 : count < 9 ? 3 : 4;
+
+const START = new Date(2026, 8, 7);
+
+const dateOf = (index: number) => {
+ const day = new Date(START);
+ day.setDate(START.getDate() + index);
+ return day.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' });
+};
+
+const CELLS: DayCell[] = MERGED.map((count, index) => ({
+ level: levelOf(count),
+ label: `${dateOf(index)}, ${count} merged`,
+}));
+
+export default function DayGridPickADay() {
+ const [day, setDay] = useState(16);
+
+ return (
+
+
+
+
+ {day === null ? 'Pick a day' : `${MERGED[day]} merged`}
+
+
{day === null ? '\u00a0' : dateOf(day)}
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/day-grid/thumb.tsx b/apps/docs/components/web-demos/day-grid/thumb.tsx
new file mode 100644
index 00000000..e46ae578
--- /dev/null
+++ b/apps/docs/components/web-demos/day-grid/thumb.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { DayGrid, type DayCell, type DayLevel } from 'panelui-web/day-grid';
+
+const LEVELS: DayLevel[] = [
+ 0, 1, 3, 2, 0, 4, 2, 1, 0, 3, 2, 4, 4, 1, 0, 2, 3, 3, 1, 0, 1, 2, 0, 3, 4, 2, 2, 0, 1, 3, 0, 0, 2, 4,
+ 3, 1, 2, 0, 1, 2,
+];
+const CELLS: DayCell[] = LEVELS.map((level) => ({ level }));
+
+export default function DayGridThumb() {
+ return ;
+}
diff --git a/apps/docs/components/web-demos/day-grid/writing.tsx b/apps/docs/components/web-demos/day-grid/writing.tsx
new file mode 100644
index 00000000..419d1b76
--- /dev/null
+++ b/apps/docs/components/web-demos/day-grid/writing.tsx
@@ -0,0 +1,77 @@
+'use client';
+
+import { DayGrid, type DayCell, type DayLevel } from 'panelui-web/day-grid';
+
+/** Words written on each of the last 112 days, oldest first, starting on a Monday. */
+const WORDS = [
+ 0, 420, 1310, 880, 0, 0, 240, 1650, 1920, 760, 0, 310, 0, 0, 980, 1440, 2210, 1180, 640, 0, 0,
+ 1890, 2460, 1730, 920, 0, 150, 0, 2050, 1380, 0, 1120, 1960, 0, 0, 870, 2320, 1610, 1240, 2580,
+ 430, 0, 0, 0, 760, 1340, 0, 0, 280, 1470, 1980, 2240, 640, 1010, 0, 0, 2120, 940, 1760, 0, 520, 0,
+ 190, 1560, 2380, 1820, 2050, 1290, 0, 0, 0, 610, 1430, 1070, 0, 0, 0, 2290, 2510, 1940, 1680, 730,
+ 0, 360, 1210, 0, 1850, 2400, 1530, 0, 0, 990, 1720, 2160, 0, 1340, 220, 0, 2070, 2450, 1890, 2630,
+ 1160, 0, 0, 1480, 2220, 1960, 2540, 870, 410, 0,
+];
+
+/** A day's count as one of the five sizes. */
+const levelOf = (words: number): DayLevel =>
+ words === 0 ? 0 : words < 600 ? 1 : words < 1300 ? 2 : words < 2000 ? 3 : 4;
+
+const START = new Date(2026, 5, 22);
+
+const CELLS: DayCell[] = WORDS.map((words, index) => {
+ const day = new Date(START);
+ day.setDate(START.getDate() + index);
+ const date = day.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
+ return {
+ level: levelOf(words),
+ label: words === 0 ? `${date}, nothing written` : `${date}, ${words.toLocaleString('en-GB')} words`,
+ };
+});
+
+const WEEKDAYS = ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'];
+
+export default function DayGridWriting() {
+ const total = WORDS.reduce((sum, words) => sum + words, 0);
+ const written = WORDS.filter((words) => words > 0).length;
+
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Words written
+
Last 16 weeks
+
+
+
+
+
+
In total
+
+ {total.toLocaleString('en-GB')}
+
+
+
+ {written} of {WORDS.length} days
+
+
+
+ {/* A week to a column: `rows` runs the days downwards. */}
+
+
+ {WEEKDAYS.map((day, index) => (
+
+ {day}
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/feedback/dialog.tsx b/apps/docs/components/web-demos/feedback/dialog.tsx
new file mode 100644
index 00000000..bfa69dc5
--- /dev/null
+++ b/apps/docs/components/web-demos/feedback/dialog.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Feedback } from 'panelui-web/feedback';
+
+export default function FeedbackDialog() {
+ const [open, setOpen] = useState(false);
+ const [message, setMessage] = useState('');
+
+ return (
+
+ }>Send feedback
+
+
+ What should we fix first?
+
+
+
+
+
+ {
+ // Send `message` here, then close and clear for next time.
+ setOpen(false);
+ setMessage('');
+ }}
+ />
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/feedback/draft.tsx b/apps/docs/components/web-demos/feedback/draft.tsx
new file mode 100644
index 00000000..c2908509
--- /dev/null
+++ b/apps/docs/components/web-demos/feedback/draft.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Feedback } from 'panelui-web/feedback';
+
+/** Short answers are rarely useful ones. */
+const ENOUGH = 20;
+
+export default function FeedbackDraft() {
+ const [open, setOpen] = useState(false);
+ // Held here, so it is still there when the dialog is opened again.
+ const [draft, setDraft] = useState('');
+ const written = draft.trim().length;
+
+ return (
+
+
+ }>
+ {written > 0 ? 'Continue your message' : 'Send feedback'}
+
+
+
+ What would you change?
+
+
+
+ {written < ENOUGH ? `${ENOUGH - written} more characters` : 'Ready to send'}
+
+
+
+ Keep for later
+ {
+ setOpen(false);
+ setDraft('');
+ }}
+ />
+
+
+
+
+ {written > 0 ? `Draft kept: ${written} characters` : 'No draft'}
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/feedback/failed.tsx b/apps/docs/components/web-demos/feedback/failed.tsx
new file mode 100644
index 00000000..3fae32d4
--- /dev/null
+++ b/apps/docs/components/web-demos/feedback/failed.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Feedback } from 'panelui-web/feedback';
+
+/** Stands in for a request. It fails once, so there is a failure to look at. */
+function send(attempt: number) {
+ return new Promise((resolve, reject) => {
+ setTimeout(() => (attempt === 0 ? reject(new Error('offline')) : resolve()), 900);
+ });
+}
+
+export default function FeedbackFailed() {
+ const [open, setOpen] = useState(false);
+ const [message, setMessage] = useState('The export button does nothing on Safari.');
+ const [status, setStatus] = useState<'idle' | 'sending' | 'failed'>('idle');
+ const [attempt, setAttempt] = useState(0);
+
+ const submit = async () => {
+ setStatus('sending');
+ try {
+ await send(attempt);
+ setOpen(false);
+ setMessage('');
+ setStatus('idle');
+ setAttempt(0);
+ } catch {
+ // The dialog is still open, so the failure has somewhere to be said
+ // and the message is still in the field to send again.
+ setStatus('failed');
+ setAttempt((current) => current + 1);
+ }
+ };
+
+ return (
+
+ }>Report a problem
+
+
+ What went wrong?
+
+
+ {status === 'failed' ? (
+
+ That did not send. Your message is still here.
+
+ ) : null}
+
+
+
+
+ {status === 'sending' ? 'Sending…' : status === 'failed' ? 'Try again' : 'Submit'}
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/feedback/in-the-page.tsx b/apps/docs/components/web-demos/feedback/in-the-page.tsx
new file mode 100644
index 00000000..c8ff16a1
--- /dev/null
+++ b/apps/docs/components/web-demos/feedback/in-the-page.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import { useState } from 'react';
+import { Feedback } from 'panelui-web/feedback';
+
+export default function FeedbackInThePage() {
+ const [message, setMessage] = useState('');
+ const [sent, setSent] = useState(false);
+
+ // Inline, there is nothing to close. Cancel and the ✕ report here instead,
+ // and this page takes that to mean "start again".
+ const reset = () => {
+ setMessage('');
+ setSent(false);
+ };
+
+ return (
+
+
+
+ {sent ? (
+ <>
+ Thank you
+
+ We read every message. If yours needs a reply, you will have one
+ within a working day.
+
+ >
+ ) : (
+ <>
+ What should we fix first?
+
+
+ >
+ )}
+
+
+ {sent ? (
+ Write another
+ ) : (
+ <>
+ Clear
+ setSent(true)} />
+ >
+ )}
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/feedback/score.tsx b/apps/docs/components/web-demos/feedback/score.tsx
new file mode 100644
index 00000000..ed24ac89
--- /dev/null
+++ b/apps/docs/components/web-demos/feedback/score.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { Feedback } from 'panelui-web/feedback';
+
+const SCORES = [1, 2, 3, 4, 5];
+
+export default function FeedbackScore() {
+ const [open, setOpen] = useState(false);
+ const [score, setScore] = useState(null);
+ const [message, setMessage] = useState('');
+
+ const close = (next: boolean) => {
+ setOpen(next);
+ if (!next) {
+ setScore(null);
+ setMessage('');
+ }
+ };
+
+ return (
+
+ }>Rate the new editor
+
+ {score === null ? (
+ <>
+
+ How is the new editor?
+
+
+
+ {SCORES.map((value) => (
+ setScore(value)}
+ className="flex size-12 cursor-pointer items-center justify-center rounded-full bg-foreground/10 text-lg font-semibold transition-colors hover:bg-primary hover:text-primary-foreground"
+ >
+ {value}
+
+ ))}
+
+
+ Worse
+ Much better
+
+
+
+
+ Not now
+
+ >
+ ) : (
+ <>
+
+
+ What made it a = 4 && 'text-emerald-500')}>{score} ?
+
+
+
+
+
+ {/* Back, not Cancel: preventDefault keeps the dialog open. */}
+ {
+ event.preventDefault();
+ setScore(null);
+ }}
+ >
+ Back
+
+ close(false)} />
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/feedback/tags.tsx b/apps/docs/components/web-demos/feedback/tags.tsx
new file mode 100644
index 00000000..35256c65
--- /dev/null
+++ b/apps/docs/components/web-demos/feedback/tags.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { Feedback } from 'panelui-web/feedback';
+
+const TAGS = ['Bug', 'Slow', 'Confusing', 'Missing feature', 'Praise'];
+
+export default function FeedbackTags() {
+ const [open, setOpen] = useState(false);
+ const [message, setMessage] = useState('');
+ const [tags, setTags] = useState([]);
+
+ const toggle = (tag: string) =>
+ setTags((current) =>
+ current.includes(tag) ? current.filter((item) => item !== tag) : [...current, tag]
+ );
+
+ // A tag on its own says something, so it is enough to send.
+ const nothingToSend = tags.length === 0 && message.trim().length === 0;
+
+ return (
+
+ }>Send feedback
+
+
+ What is this about?
+
+
+ {TAGS.map((tag) => {
+ const picked = tags.includes(tag);
+ return (
+ toggle(tag)}
+ className={cn(
+ 'h-8 cursor-pointer rounded-full px-3 text-sm font-medium transition-colors',
+ picked
+ ? 'bg-primary text-primary-foreground'
+ : 'bg-foreground/10 text-muted-foreground hover:text-foreground'
+ )}
+ >
+ {tag}
+
+ );
+ })}
+
+
+
+
+
+ {
+ setOpen(false);
+ setMessage('');
+ setTags([]);
+ }}
+ />
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/feedback/thumb.tsx b/apps/docs/components/web-demos/feedback/thumb.tsx
new file mode 100644
index 00000000..659e44dd
--- /dev/null
+++ b/apps/docs/components/web-demos/feedback/thumb.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+import { Feedback } from 'panelui-web/feedback';
+
+export default function FeedbackThumb() {
+ return (
+
+
+
+
+ What got in your way?
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/flow/groups.tsx b/apps/docs/components/web-demos/flow/groups.tsx
new file mode 100644
index 00000000..f32a783a
--- /dev/null
+++ b/apps/docs/components/web-demos/flow/groups.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import {
+ ArchiveIcon,
+ DatabaseIcon,
+ GlobeIcon,
+ HardDriveIcon,
+ ServerIcon,
+ ShieldCheckIcon,
+} from 'lucide-react';
+import { useCallback, useState, type ReactNode } from 'react';
+import { Flow } from 'panelui-web/flow';
+
+function Service({ icon, name, detail }: { icon: ReactNode; name: string; detail: string }) {
+ return (
+
+ );
+}
+
+/**
+ * Whether the canvas has room to lay the graph out across. It is the canvas
+ * that is measured and not the window: in a page with a sidebar the two are
+ * far apart.
+ */
+function useRoom(least: number) {
+ const [wide, setWide] = useState(true);
+ const watch = useCallback(
+ (node: HTMLElement | null) => {
+ if (!node) return;
+ const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= least));
+ observer.observe(node);
+ return () => observer.disconnect();
+ },
+ [least]
+ );
+ return [wide, watch] as const;
+}
+
+export default function FlowGroups() {
+ const [wide, watch] = useRoom(720);
+ // Side by side where there is room, and one under the other where there is not.
+ const from = wide ? 'right' : 'bottom';
+ const to = wide ? 'left' : 'top';
+
+ return (
+ // Taller where the groups are stacked, so they are not shrunk to fit.
+
+
+
+
+ } name="Cache" detail="96 locations" />
+ } name="Firewall" detail="Blocking 0.3%" />
+
+
+ } name="Application" detail="Six instances" />
+ } name="Database" detail="One primary, two copies" />
+
+
+ } name="Uploads" detail="2.1 TB" />
+ } name="Backups" detail="Every six hours" />
+
+
+
+
+ {/* The map is for a graph too big to see at once, which on a narrow screen this is not. */}
+ {wide ? : null}
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/flow/pipeline.tsx b/apps/docs/components/web-demos/flow/pipeline.tsx
new file mode 100644
index 00000000..6b2c0957
--- /dev/null
+++ b/apps/docs/components/web-demos/flow/pipeline.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { useCallback, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Flow } from 'panelui-web/flow';
+
+/** An article on its way to being published. */
+const STAGES = [
+ { id: 'draft', name: 'Draft', detail: '1,840 words' },
+ { id: 'edit', name: 'Edit', detail: 'Two rounds' },
+ { id: 'check', name: 'Fact check', detail: '14 claims' },
+ { id: 'legal', name: 'Legal read', detail: 'One quote to clear' },
+ { id: 'schedule', name: 'Schedule', detail: 'Thursday, 09:00' },
+ { id: 'publish', name: 'Publish', detail: 'Site and newsletter' },
+];
+
+const TAG = {
+ done: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-(--success)',
+ live: 'bg-[color-mix(in_oklab,var(--info)_14%,transparent)] text-(--info)',
+ waiting: 'bg-muted text-muted-foreground',
+};
+
+/**
+ * Whether the canvas has room to lay the graph out across. It is the canvas
+ * that is measured and not the window: in a page with a sidebar the two are
+ * far apart.
+ */
+function useRoom(least: number) {
+ const [wide, setWide] = useState(true);
+ const watch = useCallback(
+ (node: HTMLElement | null) => {
+ if (!node) return;
+ const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= least));
+ observer.observe(node);
+ return () => observer.disconnect();
+ },
+ [least]
+ );
+ return [wide, watch] as const;
+}
+
+export default function FlowPipeline() {
+ // The stage being worked on. Everything before it is done.
+ const [at, setAt] = useState(1);
+ const [wide, watch] = useRoom(720);
+ const columns = wide ? 3 : 2;
+
+ // The stages run along a row and back along the next, like a snake.
+ const place = (index: number) => {
+ const row = Math.floor(index / columns);
+ const along = index % columns;
+ const column = row % 2 ? columns - 1 - along : along;
+ return { x: column * (wide ? 340 : 216), y: row * (wide ? 230 : 160) };
+ };
+
+ return (
+
+
+
+ {STAGES.map((stage, index) => {
+ const state = index < at ? 'done' : index === at ? 'live' : 'waiting';
+ return (
+
+
+ Stage {index + 1}
+
+ {state === 'done' ? 'Done' : state === 'live' ? 'Now' : 'Waiting'}
+
+
+
+
{stage.name}
+
{stage.detail}
+
+
+ );
+ })}
+ {STAGES.slice(1).map((stage, index) => (
+
+ ))}
+ {/* Fewer buttons where the graph needs the room. */}
+
+
+
+
+ setAt(at - 1)}>
+ Back a stage
+
+ setAt(at + 1)}>
+ Advance
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/flow/thumb.tsx b/apps/docs/components/web-demos/flow/thumb.tsx
new file mode 100644
index 00000000..e56ec493
--- /dev/null
+++ b/apps/docs/components/web-demos/flow/thumb.tsx
@@ -0,0 +1,21 @@
+'use client';
+
+import { Flow } from 'panelui-web/flow';
+
+export default function FlowThumb() {
+ return (
+
+
+ Form
+
+
+ Verify
+
+
+ Welcome
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/flow/wiring.tsx b/apps/docs/components/web-demos/flow/wiring.tsx
new file mode 100644
index 00000000..613eaf35
--- /dev/null
+++ b/apps/docs/components/web-demos/flow/wiring.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { useCallback, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Flow, type FlowConnection } from 'panelui-web/flow';
+
+/** Each step, and where it sits: across a wide screen, down a narrow one. */
+const NODES = [
+ {
+ id: 'form',
+ name: 'Sign-up form',
+ across: { x: 0, y: 90 },
+ down: { x: 0, y: 0 },
+ },
+ {
+ id: 'verify',
+ name: 'Verify email',
+ across: { x: 320, y: 0 },
+ down: { x: 150, y: 80 },
+ },
+ {
+ id: 'welcome',
+ name: 'Welcome message',
+ across: { x: 320, y: 180 },
+ down: { x: 0, y: 160 },
+ },
+ {
+ id: 'crm',
+ name: 'Add to CRM',
+ across: { x: 680, y: 90 },
+ down: { x: 150, y: 240 },
+ },
+];
+
+/**
+ * Whether the canvas has room to lay the graph out across. It is the canvas
+ * that is measured and not the window: in a page with a sidebar the two are
+ * far apart.
+ */
+function useRoom(least: number) {
+ const [wide, setWide] = useState(true);
+ const watch = useCallback(
+ (node: HTMLElement | null) => {
+ if (!node) return;
+ const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= least));
+ observer.observe(node);
+ return () => observer.disconnect();
+ },
+ [least]
+ );
+ return [wide, watch] as const;
+}
+
+const START: FlowConnection[] = [{ source: 'form', target: 'verify' }];
+
+export default function FlowWiring() {
+ const [wires, setWires] = useState(START);
+ const [wide, watch] = useRoom(720);
+ // A wire leaves one face of a step and arrives at the opposite one.
+ const out = wide ? 'right' : 'bottom';
+ const into = wide ? 'left' : 'top';
+
+ const joined = (a: FlowConnection, b: FlowConnection) =>
+ a.source === b.source && a.target === b.target;
+
+ return (
+
+
!wires.some((wire) => joined(wire, connection))}
+ onConnect={(connection) => setWires((current) => [...current, connection])}
+ >
+
+ {NODES.map((node) => (
+
+ {node.name}
+
+
+
+ ))}
+ {wires.map((wire) => (
+
+ ))}
+
+
+
+
+
+ {wide ? 'Drag from a dot on one step to another step to join them. ' : null}
+ {wires.length} joined.
+
+
setWires(START)}>
+ Reset
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/index.generated.tsx b/apps/docs/components/web-demos/index.generated.tsx
new file mode 100644
index 00000000..6cebc6cf
--- /dev/null
+++ b/apps/docs/components/web-demos/index.generated.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+// Generated by scripts/gen-web.mjs from the demos in this folder. Do not edit.
+import dynamic from 'next/dynamic';
+import type { ComponentType } from 'react';
+
+/** Every demo a docs page shows, each in a chunk of its own. */
+export const WEB_DEMOS: Record = {
+ 'bar-picker/coverage': dynamic(() => import('./bar-picker/coverage')),
+ 'bar-picker/deploys': dynamic(() => import('./bar-picker/deploys')),
+ 'bar-picker/thumb': dynamic(() => import('./bar-picker/thumb')),
+ 'command-dock/choose': dynamic(() => import('./command-dock/choose')),
+ 'command-dock/find': dynamic(() => import('./command-dock/find')),
+ 'command-dock/thumb': dynamic(() => import('./command-dock/thumb')),
+ 'day-grid/colours': dynamic(() => import('./day-grid/colours')),
+ 'day-grid/pick-a-day': dynamic(() => import('./day-grid/pick-a-day')),
+ 'day-grid/thumb': dynamic(() => import('./day-grid/thumb')),
+ 'day-grid/writing': dynamic(() => import('./day-grid/writing')),
+ 'feedback/dialog': dynamic(() => import('./feedback/dialog')),
+ 'feedback/draft': dynamic(() => import('./feedback/draft')),
+ 'feedback/failed': dynamic(() => import('./feedback/failed')),
+ 'feedback/in-the-page': dynamic(() => import('./feedback/in-the-page')),
+ 'feedback/score': dynamic(() => import('./feedback/score')),
+ 'feedback/tags': dynamic(() => import('./feedback/tags')),
+ 'feedback/thumb': dynamic(() => import('./feedback/thumb')),
+ 'flow/groups': dynamic(() => import('./flow/groups')),
+ 'flow/pipeline': dynamic(() => import('./flow/pipeline')),
+ 'flow/thumb': dynamic(() => import('./flow/thumb')),
+ 'flow/wiring': dynamic(() => import('./flow/wiring')),
+ 'kpi/card': dynamic(() => import('./kpi/card')),
+ 'kpi/overview': dynamic(() => import('./kpi/overview')),
+ 'kpi/panel': dynamic(() => import('./kpi/panel')),
+ 'kpi/targets': dynamic(() => import('./kpi/targets')),
+ 'kpi/thumb': dynamic(() => import('./kpi/thumb')),
+ 'lane-board/release': dynamic(() => import('./lane-board/release')),
+ 'lane-board/rules': dynamic(() => import('./lane-board/rules')),
+ 'lane-board/sprint': dynamic(() => import('./lane-board/sprint')),
+ 'lane-board/thumb': dynamic(() => import('./lane-board/thumb')),
+ 'planet/fly-to': dynamic(() => import('./planet/fly-to')),
+ 'planet/people': dynamic(() => import('./planet/people')),
+ 'planet/pins': dynamic(() => import('./planet/pins')),
+ 'planet/regions': dynamic(() => import('./planet/regions')),
+ 'planet/thumb': dynamic(() => import('./planet/thumb')),
+ 'planet/turning': dynamic(() => import('./planet/turning')),
+ 'range-meter/brightness': dynamic(() => import('./range-meter/brightness')),
+ 'range-meter/readings': dynamic(() => import('./range-meter/readings')),
+ 'range-meter/response-time': dynamic(() => import('./range-meter/response-time')),
+ 'range-meter/thumb': dynamic(() => import('./range-meter/thumb')),
+ 'rolling-number/currency': dynamic(() => import('./rolling-number/currency')),
+ 'rolling-number/formats': dynamic(() => import('./rolling-number/formats')),
+ 'rolling-number/queue': dynamic(() => import('./rolling-number/queue')),
+ 'rolling-number/slow': dynamic(() => import('./rolling-number/slow')),
+ 'rolling-number/thumb': dynamic(() => import('./rolling-number/thumb')),
+ 'tilt-card/figures': dynamic(() => import('./tilt-card/figures')),
+ 'tilt-card/plain': dynamic(() => import('./tilt-card/plain')),
+ 'tilt-card/thumb': dynamic(() => import('./tilt-card/thumb')),
+};
diff --git a/apps/docs/components/web-demos/kpi/card.tsx b/apps/docs/components/web-demos/kpi/card.tsx
new file mode 100644
index 00000000..fcf42d83
--- /dev/null
+++ b/apps/docs/components/web-demos/kpi/card.tsx
@@ -0,0 +1,23 @@
+import { UsersIcon } from 'lucide-react';
+import { Kpi } from 'panelui-web/kpi';
+
+const READERS = [310, 318, 305, 322, 340, 336, 351, 348, 366, 372, 369, 388, 401, 412];
+
+export default function KpiCard() {
+ return (
+
+
+
+
+
+ Weekly readers
+
+
+
+
+ 4,120
+
+ Counted every Monday
+
+ );
+}
diff --git a/apps/docs/components/web-demos/kpi/overview.tsx b/apps/docs/components/web-demos/kpi/overview.tsx
new file mode 100644
index 00000000..2832cab7
--- /dev/null
+++ b/apps/docs/components/web-demos/kpi/overview.tsx
@@ -0,0 +1,53 @@
+import { Kpi } from 'panelui-web/kpi';
+
+/** Each figure over the last thirty days, oldest first. */
+const SUBSCRIBERS = [152, 154, 153, 157, 161, 160, 164, 169, 168, 172, 176, 175, 179, 182];
+const UNSUBSCRIBES = [14, 13, 13, 12, 12, 11, 12, 10, 10, 9, 9, 8, 8, 7];
+const OPENS = [41, 42, 44, 43, 45, 44, 46, 45, 47, 46, 46, 48, 47, 47];
+
+export default function KpiOverview() {
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Newsletter
+
Last 30 days
+
+
+
+
+
+
+
+ Subscribers
+ 18,240
+
+
+
+
+
+
+
+
+ Unsubscribe rate
+ 0.8%
+
+
+
+
+
+
+
+
+ Open rate
+ 47.3%
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/kpi/panel.tsx b/apps/docs/components/web-demos/kpi/panel.tsx
new file mode 100644
index 00000000..b100b1c9
--- /dev/null
+++ b/apps/docs/components/web-demos/kpi/panel.tsx
@@ -0,0 +1,25 @@
+import { Kpi } from 'panelui-web/kpi';
+
+export default function KpiPanel() {
+ return (
+
+
+
+ Sent
+ 112
+
+
+
+ Replies
+ 386
+
+
+
+ Bounces
+ 0.6%
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/kpi/targets.tsx b/apps/docs/components/web-demos/kpi/targets.tsx
new file mode 100644
index 00000000..976b6b69
--- /dev/null
+++ b/apps/docs/components/web-demos/kpi/targets.tsx
@@ -0,0 +1,52 @@
+import { BanknoteIcon, TargetIcon } from 'lucide-react';
+import { Kpi } from 'panelui-web/kpi';
+
+export default function KpiTargets() {
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Targets
+
This quarter
+
+
+
+
+
+
+
+
+
+ Paid members
+
+
+
+
+ 1,463
+
+ 38 days left in the quarter
+
+
+
+
+
+
+ Sponsorship
+
+
+
+
+ €9,800
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/kpi/thumb.tsx b/apps/docs/components/web-demos/kpi/thumb.tsx
new file mode 100644
index 00000000..6712bf23
--- /dev/null
+++ b/apps/docs/components/web-demos/kpi/thumb.tsx
@@ -0,0 +1,18 @@
+import { Kpi } from 'panelui-web/kpi';
+
+const READERS = [31, 32, 30, 34, 36, 35, 38, 41];
+
+export default function KpiThumb() {
+ return (
+
+
+
+ Readers
+ 4,120
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/lane-board/release.tsx b/apps/docs/components/web-demos/lane-board/release.tsx
new file mode 100644
index 00000000..30b623a4
--- /dev/null
+++ b/apps/docs/components/web-demos/lane-board/release.tsx
@@ -0,0 +1,77 @@
+'use client';
+
+import { useState } from 'react';
+import { LaneBoard, type LaneBoardLane } from 'panelui-web/lane-board';
+
+interface Change {
+ id: string;
+ title: string;
+ /** How many of the five checks have passed. */
+ checks: number;
+ /** How long it has been in its lane. */
+ waiting: string;
+}
+
+/** The changes going into one release, by how far along each is. */
+const START: LaneBoardLane[] = [
+ {
+ id: 'queued',
+ title: 'Queued',
+ cards: [
+ { id: 'REL-231', title: 'Offline drafts', checks: 0, waiting: '2d' },
+ { id: 'REL-238', title: 'Export to CSV', checks: 1, waiting: '5h' },
+ ],
+ },
+ {
+ id: 'building',
+ title: 'Building',
+ cards: [
+ { id: 'REL-224', title: 'Faster search', checks: 2, waiting: '1d' },
+ { id: 'REL-229', title: 'Invite by link', checks: 3, waiting: '3h' },
+ { id: 'REL-233', title: 'Keyboard shortcuts', checks: 2, waiting: '40m' },
+ { id: 'REL-236', title: 'Saved filters', checks: 1, waiting: '25m' },
+ { id: 'REL-240', title: 'Bulk archive', checks: 0, waiting: '10m' },
+ ],
+ },
+ {
+ id: 'review',
+ title: 'In review',
+ cards: [{ id: 'REL-219', title: 'Audit log', checks: 4, waiting: '6h' }],
+ },
+ {
+ id: 'shipped',
+ title: 'Shipped',
+ cards: [
+ { id: 'REL-207', title: 'Two-step sign in', checks: 5, waiting: '' },
+ { id: 'REL-212', title: 'Dark charts', checks: 5, waiting: '' },
+ ],
+ },
+];
+
+export default function LaneBoardRelease() {
+ const [lanes, setLanes] = useState(START);
+
+ return (
+ change.title}
+ renderCard={(change, { lane }) => (
+ <>
+ {change.title}
+ {change.id}
+
+
+ {lane.id === 'shipped' ? (
+ Live
+ ) : (
+ {change.waiting}
+ )}
+
+ >
+ )}
+ />
+ );
+}
diff --git a/apps/docs/components/web-demos/lane-board/rules.tsx b/apps/docs/components/web-demos/lane-board/rules.tsx
new file mode 100644
index 00000000..352d54a1
--- /dev/null
+++ b/apps/docs/components/web-demos/lane-board/rules.tsx
@@ -0,0 +1,64 @@
+'use client';
+
+import { useState } from 'react';
+import { LaneBoard, type LaneBoardLane } from 'panelui-web/lane-board';
+
+interface Task {
+ id: string;
+ title: string;
+ /** How many of its three steps are done. */
+ done: number;
+}
+
+const START: LaneBoardLane[] = [
+ {
+ id: 'todo',
+ title: 'To do',
+ cards: [
+ { id: 'T-14', title: 'Write the migration', done: 1 },
+ { id: 'T-17', title: 'Update the runbook', done: 3 },
+ ],
+ },
+ { id: 'doing', title: 'Doing', cards: [{ id: 'T-11', title: 'Rotate the keys', done: 2 }] },
+ { id: 'done', title: 'Done', cards: [] },
+];
+
+export default function LaneBoardRules() {
+ const [lanes, setLanes] = useState(START);
+ const [refused, setRefused] = useState(null);
+
+ return (
+
+
{
+ const unfinished = next
+ .find((lane) => lane.id === 'done')
+ ?.cards.find((task) => task.done < 3);
+ if (unfinished) {
+ setRefused(unfinished.title);
+ return;
+ }
+ setRefused(null);
+ setLanes(next);
+ }}
+ cardName={(task) => task.title}
+ renderCard={(task) => (
+ <>
+ {task.title}
+
+
+ {task.done}/3
+
+ >
+ )}
+ />
+
+ {refused ? `${refused} still has steps left, so it stays where it was.` : 'Only a task with all three steps done can go to Done.'}
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/lane-board/sprint.tsx b/apps/docs/components/web-demos/lane-board/sprint.tsx
new file mode 100644
index 00000000..2570a748
--- /dev/null
+++ b/apps/docs/components/web-demos/lane-board/sprint.tsx
@@ -0,0 +1,117 @@
+'use client';
+
+import { PlusIcon } from 'lucide-react';
+import { useState } from 'react';
+import { LaneBoard, type LaneBoardColor, type LaneBoardLane } from 'panelui-web/lane-board';
+
+type Priority = 'Low' | 'Normal' | 'Urgent';
+
+interface Ticket {
+ id: string;
+ title: string;
+ priority: Priority;
+ /** Whose it is, as initials. */
+ owner: string;
+ /** How many of its four checks have passed. */
+ checks: number;
+}
+
+const TONE: Record = {
+ Low: 'primary',
+ Normal: 'info',
+ Urgent: 'destructive',
+};
+
+/** One sprint of a support team. Each lane has a colour, and two have a limit. */
+const START: LaneBoardLane[] = [
+ {
+ id: 'inbox',
+ title: 'Inbox',
+ color: 'primary',
+ cards: [
+ { id: 'SUP-481', title: 'Refund not showing', priority: 'Urgent', owner: 'NO', checks: 0 },
+ { id: 'SUP-479', title: 'Change the invoice name', priority: 'Low', owner: 'HL', checks: 0 },
+ ],
+ },
+ {
+ id: 'working',
+ title: 'Working on',
+ color: 'info',
+ limit: 2,
+ cards: [
+ { id: 'SUP-472', title: 'Login loop on tablets', priority: 'Urgent', owner: 'TA', checks: 2 },
+ { id: 'SUP-476', title: 'Export stops at row 500', priority: 'Normal', owner: 'NO', checks: 1 },
+ ],
+ },
+ {
+ id: 'waiting',
+ title: 'Waiting on customer',
+ color: 'warning',
+ limit: 3,
+ cards: [{ id: 'SUP-468', title: 'Which plan were you on?', priority: 'Normal', owner: 'HL', checks: 3 }],
+ },
+ {
+ id: 'solved',
+ title: 'Solved',
+ color: 'success',
+ cards: [{ id: 'SUP-461', title: 'Password reset email', priority: 'Low', owner: 'TA', checks: 4 }],
+ },
+];
+
+export default function LaneBoardSprint() {
+ const [lanes, setLanes] = useState(START);
+ const [made, setMade] = useState(0);
+
+ const add = (laneId: string) => {
+ const id = `SUP-${482 + made}`;
+ setMade(made + 1);
+ setLanes((current) =>
+ current.map((lane) =>
+ lane.id === laneId
+ ? {
+ ...lane,
+ cards: [...lane.cards, { id, title: 'New ticket', priority: 'Normal', owner: 'NO', checks: 0 }],
+ }
+ : lane
+ )
+ );
+ };
+
+ return (
+ ticket.title}
+ renderCard={(ticket) => (
+ <>
+
+
+ {ticket.title}
+
+
+ {ticket.owner}
+
+
+ {ticket.id}
+
+ {ticket.priority}
+
+
+ >
+ )}
+ renderLaneFooter={(lane) =>
+ lane.id === 'inbox' ? (
+ add(lane.id)}
+ className="flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-2xl text-sm text-muted-foreground outline-none transition-colors hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
+ >
+
+ Add a ticket
+
+ ) : null
+ }
+ />
+ );
+}
diff --git a/apps/docs/components/web-demos/lane-board/thumb.tsx b/apps/docs/components/web-demos/lane-board/thumb.tsx
new file mode 100644
index 00000000..777652d0
--- /dev/null
+++ b/apps/docs/components/web-demos/lane-board/thumb.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { LaneBoard, type LaneBoardLane } from 'panelui-web/lane-board';
+
+interface Item {
+ id: string;
+ title: string;
+ steps: number;
+}
+
+const LANES: LaneBoardLane- [] = [
+ {
+ id: 'a',
+ title: 'Building',
+ cards: [
+ { id: 'A-1', title: 'Faster search', steps: 2 },
+ { id: 'A-2', title: 'Invite by link', steps: 3 },
+ ],
+ },
+ { id: 'b', title: 'Shipped', cards: [{ id: 'B-1', title: 'Audit log', steps: 5 }] },
+];
+
+export default function LaneBoardThumb() {
+ return (
+
+ (
+ <>
+ {item.title}
+ {item.id}
+
+
+
+ >
+ )}
+ />
+
+ );
+}
diff --git a/apps/docs/components/web-demos/planet/fly-to.tsx b/apps/docs/components/web-demos/planet/fly-to.tsx
new file mode 100644
index 00000000..93eced01
--- /dev/null
+++ b/apps/docs/components/web-demos/planet/fly-to.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { useRef, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Planet, type PlanetHandle } from 'panelui-web/planet';
+
+const CITIES = [
+ { name: 'Tokyo', lat: 35.68, lng: 139.69 },
+ { name: 'Nairobi', lat: -1.29, lng: 36.82 },
+ { name: 'Reykjavík', lat: 64.15, lng: -21.94 },
+ { name: 'São Paulo', lat: -23.55, lng: -46.63 },
+];
+
+export default function PlanetFlyTo() {
+ const planet = useRef(null);
+ const [city, setCity] = useState(CITIES[0].name);
+
+ return (
+
+
+
+ {CITIES.map((place) => (
+
+ ))}
+
+
+
+
+ {CITIES.map((place) => (
+ {
+ setCity(place.name);
+ // The latitude is eased towards the equator so a city near a
+ // pole is seen from the side, not from directly above.
+ planet.current?.rotateTo([place.lng, place.lat * 0.6]);
+ }}
+ >
+ {place.name}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/planet/people.tsx b/apps/docs/components/web-demos/planet/people.tsx
new file mode 100644
index 00000000..d844e3a1
--- /dev/null
+++ b/apps/docs/components/web-demos/planet/people.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { Planet } from 'panelui-web/planet';
+
+/** Where a remote team is working from this week. */
+const TEAM = [
+ { initials: 'IF', city: 'Lisbon', lat: 38.72, lng: -9.14 },
+ { initials: 'TN', city: 'Nairobi', lat: -1.29, lng: 36.82 },
+ { initials: 'KS', city: 'Tbilisi', lat: 41.72, lng: 44.79 },
+ { initials: 'RA', city: 'Colombo', lat: 6.93, lng: 79.85 },
+ { initials: 'MV', city: 'Helsinki', lat: 60.17, lng: 24.94 },
+];
+
+export default function PlanetPeople() {
+ return (
+
+
+ {TEAM.map((person) => (
+
+
+ {person.initials}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/planet/pins.tsx b/apps/docs/components/web-demos/planet/pins.tsx
new file mode 100644
index 00000000..612e9c5c
--- /dev/null
+++ b/apps/docs/components/web-demos/planet/pins.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import { Planet } from 'panelui-web/planet';
+
+const OFFICES = [
+ { city: 'London', lat: 51.51, lng: -0.13 },
+ { city: 'Lagos', lat: 6.52, lng: 3.38 },
+ { city: 'Dubai', lat: 25.2, lng: 55.27 },
+ { city: 'Bengaluru', lat: 12.97, lng: 77.59 },
+ { city: 'Singapore', lat: 1.35, lng: 103.82 },
+];
+
+export default function PlanetPins() {
+ return (
+
+
+ {OFFICES.map((office) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/planet/regions.tsx b/apps/docs/components/web-demos/planet/regions.tsx
new file mode 100644
index 00000000..a55e9f7c
--- /dev/null
+++ b/apps/docs/components/web-demos/planet/regions.tsx
@@ -0,0 +1,120 @@
+'use client';
+
+import { useRef, useState } from 'react';
+import { Planet, type PlanetHandle, type PlanetMarkerColor } from 'panelui-web/planet';
+
+type Health = 'healthy' | 'slow' | 'down';
+
+/** The data centres a service runs in, and how each is answering. */
+const REGIONS: { city: string; lat: number; lng: number; health: Health; ms: number | null }[] = [
+ { city: 'Dublin', lat: 53.35, lng: -6.26, health: 'healthy', ms: 38 },
+ { city: 'Warsaw', lat: 52.23, lng: 21.01, health: 'healthy', ms: 44 },
+ { city: 'Johannesburg', lat: -26.2, lng: 28.05, health: 'slow', ms: 412 },
+ { city: 'Mumbai', lat: 19.08, lng: 72.88, health: 'healthy', ms: 61 },
+ { city: 'Singapore', lat: 1.35, lng: 103.82, health: 'healthy', ms: 52 },
+ { city: 'Sydney', lat: -33.87, lng: 151.21, health: 'down', ms: null },
+ { city: 'Seoul', lat: 37.57, lng: 126.98, health: 'healthy', ms: 47 },
+ { city: 'São Paulo', lat: -23.55, lng: -46.63, health: 'healthy', ms: 73 },
+ { city: 'Montreal', lat: 45.5, lng: -73.57, health: 'healthy', ms: 41 },
+ { city: 'Portland', lat: 45.52, lng: -122.68, health: 'slow', ms: 286 },
+];
+
+const COLOUR: Record = {
+ healthy: 'success',
+ slow: 'warning',
+ down: 'destructive',
+};
+
+/** The same three colours for the dots in the list. */
+const DOT: Record = {
+ healthy: 'bg-(--success)',
+ slow: 'bg-(--warning)',
+ down: 'bg-destructive',
+};
+
+const SAYS: Record = {
+ healthy: 'Answering normally',
+ slow: 'Answering slowly',
+ down: 'Not answering',
+};
+
+export default function PlanetRegions() {
+ const planet = useRef(null);
+ const [picked, setPicked] = useState('Singapore');
+ const region = REGIONS.find((entry) => entry.city === picked) ?? REGIONS[0];
+ const healthy = REGIONS.filter((entry) => entry.health === 'healthy').length;
+
+ const pick = (city: string) => {
+ const next = REGIONS.find((entry) => entry.city === city);
+ if (!next) return;
+ setPicked(city);
+ // Turned so the region faces you, a little short of its latitude so a
+ // place far north or south is seen from the side.
+ planet.current?.rotateTo([next.lng, next.lat * 0.5]);
+ };
+
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Regions
+
+ {healthy} of {REGIONS.length} healthy
+
+
+
+ {/* The globe beside the list on a wide screen, over it on a narrow one. */}
+
+
+
+ {REGIONS.map((entry) => (
+ pick(entry.city)}
+ />
+ ))}
+
+
+
+
+
+
+
+
{region.city}
+
{SAYS[region.health]}
+
+
+ {region.ms === null ? '\u2014' : region.ms}
+ {region.ms === null ? null : (
+ ms
+ )}
+
+
+
+
+ {REGIONS.map((entry) => (
+
+ pick(entry.city)}
+ className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-start text-sm text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-muted aria-pressed:text-foreground"
+ >
+
+ {entry.city}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/planet/thumb.tsx b/apps/docs/components/web-demos/planet/thumb.tsx
new file mode 100644
index 00000000..ac951daf
--- /dev/null
+++ b/apps/docs/components/web-demos/planet/thumb.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import { Planet } from 'panelui-web/planet';
+
+export default function PlanetThumb() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/planet/turning.tsx b/apps/docs/components/web-demos/planet/turning.tsx
new file mode 100644
index 00000000..3219368e
--- /dev/null
+++ b/apps/docs/components/web-demos/planet/turning.tsx
@@ -0,0 +1,11 @@
+'use client';
+
+import { Planet } from 'panelui-web/planet';
+
+export default function PlanetTurning() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/range-meter/brightness.tsx b/apps/docs/components/web-demos/range-meter/brightness.tsx
new file mode 100644
index 00000000..0c9c5a0a
--- /dev/null
+++ b/apps/docs/components/web-demos/range-meter/brightness.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { SunIcon } from 'lucide-react';
+import { useState } from 'react';
+import { RangeMeter } from 'panelui-web/range-meter';
+
+export default function RangeMeterBrightness() {
+ const [brightness, setBrightness] = useState(65);
+
+ return (
+
+
`${value}%`}
+ label="Brightness"
+ valueText={`${brightness} percent`}
+ >
+
+
+
+ Dim
+ Bright
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/range-meter/readings.tsx b/apps/docs/components/web-demos/range-meter/readings.tsx
new file mode 100644
index 00000000..304f6f31
--- /dev/null
+++ b/apps/docs/components/web-demos/range-meter/readings.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import { RangeMeter } from 'panelui-web/range-meter';
+
+/** Three readings nobody can change: without `onValueChange` it is a meter. */
+const READINGS = [
+ { name: 'Build cache', used: 71, color: 'primary' },
+ { name: 'Artifact storage', used: 88, color: 'warning' },
+ { name: 'Log retention', used: 34, color: 'info' },
+] as const;
+
+export default function RangeMeterReadings() {
+ return (
+
+ {READINGS.map((reading) => (
+
+ {reading.name}
+ `${value}%`}
+ label={reading.name}
+ valueText={`${reading.used} percent used`}
+ />
+
+ ))}
+
+ );
+}
diff --git a/apps/docs/components/web-demos/range-meter/response-time.tsx b/apps/docs/components/web-demos/range-meter/response-time.tsx
new file mode 100644
index 00000000..c4f11fc9
--- /dev/null
+++ b/apps/docs/components/web-demos/range-meter/response-time.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { useState } from 'react';
+import { RangeMeter, type RangeMeterColor } from 'panelui-web/range-meter';
+
+const FASTEST = 80;
+const SLOWEST = 600;
+/** What the team has agreed a page may take. */
+const BUDGET = 300;
+
+/** Fine under the budget, a warning up to half as much again, a problem past that. */
+const colorOf = (time: number): RangeMeterColor =>
+ time <= BUDGET ? 'success' : time <= BUDGET * 1.5 ? 'warning' : 'destructive';
+
+const verdictOf = (time: number) =>
+ time <= BUDGET ? `${BUDGET - time} ms inside budget` : `${time - BUDGET} ms over budget`;
+
+export default function RangeMeterResponseTime() {
+ const [time, setTime] = useState(240);
+
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Checkout page
+
Budget {BUDGET} ms
+
+
+
+
+
+
Response time
+
+ {time}
+ ms
+
+
+
+ {verdictOf(time)}
+
+
+
+
+
+
+ {FASTEST} ms
+ {SLOWEST} ms
+
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/range-meter/thumb.tsx b/apps/docs/components/web-demos/range-meter/thumb.tsx
new file mode 100644
index 00000000..959385b6
--- /dev/null
+++ b/apps/docs/components/web-demos/range-meter/thumb.tsx
@@ -0,0 +1,7 @@
+'use client';
+
+import { RangeMeter } from 'panelui-web/range-meter';
+
+export default function RangeMeterThumb() {
+ return ;
+}
diff --git a/apps/docs/components/web-demos/rolling-number/currency.tsx b/apps/docs/components/web-demos/rolling-number/currency.tsx
new file mode 100644
index 00000000..d4f4f6e6
--- /dev/null
+++ b/apps/docs/components/web-demos/rolling-number/currency.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { RollingNumber } from 'panelui-web/rolling-number';
+
+export default function RollingNumberCurrency() {
+ const [total, setTotal] = useState(1864.5);
+
+ return (
+
+
+ Trip budget left
+
+
+
+ setTotal((value) => value - 72.4)}>
+ Book a train
+
+ setTotal((value) => value + 350)}>
+ Add a refund
+
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/rolling-number/formats.tsx b/apps/docs/components/web-demos/rolling-number/formats.tsx
new file mode 100644
index 00000000..070c6a81
--- /dev/null
+++ b/apps/docs/components/web-demos/rolling-number/formats.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { RollingNumber } from 'panelui-web/rolling-number';
+
+/** Three weeks after a launch. */
+const WEEKS = [
+ { downloads: 18420, paid: 0.031, sales: 46200 },
+ { downloads: 21960, paid: 0.038, sales: 61850 },
+ { downloads: 19310, paid: 0.027, sales: 39400 },
+];
+
+export default function RollingNumberFormats() {
+ const [week, setWeek] = useState(0);
+ const { downloads, paid, sales } = WEEKS[week];
+
+ return (
+
+
+
+
Downloads
+
+
+
+
+
+
Trial to paid
+
+
+
+
+
+
Sales
+
+
+
+
+
+
setWeek((current) => (current + 1) % WEEKS.length)}
+ >
+ Next week
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/rolling-number/queue.tsx b/apps/docs/components/web-demos/rolling-number/queue.tsx
new file mode 100644
index 00000000..3687a7f7
--- /dev/null
+++ b/apps/docs/components/web-demos/rolling-number/queue.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { RollingNumber } from 'panelui-web/rolling-number';
+
+/** Jobs waiting in a queue, read every couple of seconds. */
+const WAITING = [312, 298, 305, 341, 327, 289];
+
+export default function RollingNumberQueue() {
+ const [reading, setReading] = useState(0);
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setReading((current) => (current + 1) % WAITING.length);
+ }, 2200);
+ return () => clearInterval(timer);
+ }, []);
+
+ return (
+
+ Jobs waiting
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/rolling-number/slow.tsx b/apps/docs/components/web-demos/rolling-number/slow.tsx
new file mode 100644
index 00000000..6aa4171c
--- /dev/null
+++ b/apps/docs/components/web-demos/rolling-number/slow.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { RollingNumber } from 'panelui-web/rolling-number';
+
+export default function RollingNumberSlow() {
+ const [score, setScore] = useState(2048);
+
+ return (
+
+
+ setScore((value) => value * 2)}>
+ Double it
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/rolling-number/thumb.tsx b/apps/docs/components/web-demos/rolling-number/thumb.tsx
new file mode 100644
index 00000000..e495f217
--- /dev/null
+++ b/apps/docs/components/web-demos/rolling-number/thumb.tsx
@@ -0,0 +1,15 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { RollingNumber } from 'panelui-web/rolling-number';
+
+const READINGS = [4820, 4865, 4791, 4903];
+
+export default function RollingNumberThumb() {
+ const [reading, setReading] = useState(0);
+ useEffect(() => {
+ const timer = setInterval(() => setReading((current) => (current + 1) % READINGS.length), 2000);
+ return () => clearInterval(timer);
+ }, []);
+ return ;
+}
diff --git a/apps/docs/components/web-demos/tilt-card/figures.tsx b/apps/docs/components/web-demos/tilt-card/figures.tsx
new file mode 100644
index 00000000..5c523c4f
--- /dev/null
+++ b/apps/docs/components/web-demos/tilt-card/figures.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { TiltCard, type TiltCardColor } from 'panelui-web/tilt-card';
+
+/** Three sheets, one over the other, each lifted further off the card. */
+function Sheets() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Two rings and a body on the outer one. */
+function Orbit() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** A field of marks, with one of them picked out and nearer. */
+function Field() {
+ return (
+
+ {Array.from({ length: 16 }, (_, index) =>
+ index === 6 ? (
+
+ ) : (
+
+ )
+ )}
+
+ );
+}
+
+const CARDS: { title: string; body: string; color: TiltCardColor; figure: ReactNode }[] = [
+ { title: 'Versions', body: 'Every save is kept, and any of them can be brought back.', color: 'success', figure: },
+ { title: 'Sync', body: 'Changes reach your other devices within a few seconds.', color: 'info', figure: },
+ { title: 'Search', body: 'One box finds a word in any note, however old.', color: 'warning', figure: },
+];
+
+export default function TiltCardFigures() {
+ return (
+
+ {CARDS.map((card) => (
+
+ {card.figure}
+
+ {card.title}
+ {card.body}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/docs/components/web-demos/tilt-card/plain.tsx b/apps/docs/components/web-demos/tilt-card/plain.tsx
new file mode 100644
index 00000000..3626c890
--- /dev/null
+++ b/apps/docs/components/web-demos/tilt-card/plain.tsx
@@ -0,0 +1,17 @@
+'use client';
+
+import { TiltCard } from 'panelui-web/tilt-card';
+
+export default function TiltCardPlain() {
+ return (
+
+ Team plan
+
+ €12 a seat, monthly
+
+
+ Shared notebooks, version history for a year, and single sign-on.
+
+
+ );
+}
diff --git a/apps/docs/components/web-demos/tilt-card/thumb.tsx b/apps/docs/components/web-demos/tilt-card/thumb.tsx
new file mode 100644
index 00000000..35958755
--- /dev/null
+++ b/apps/docs/components/web-demos/tilt-card/thumb.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import { TiltCard } from 'panelui-web/tilt-card';
+
+export default function TiltCardThumb() {
+ return (
+
+
+ Versions
+
+ );
+}
diff --git a/apps/docs/content/docs/web/components/bar-picker.mdx b/apps/docs/content/docs/web/components/bar-picker.mdx
new file mode 100644
index 00000000..1fcfacea
--- /dev/null
+++ b/apps/docs/content/docs/web/components/bar-picker.mdx
@@ -0,0 +1,220 @@
+---
+title: Bar Picker
+description: A short run of values as columns of lights, one column picked.
+status: new
+---
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { BarPicker, type BarPickerBar } from '@/components/panelui/bar-picker';
+
+/** Deploys to production on each day of one week. */
+const WEEK: BarPickerBar[] = [
+ { label: 'Mon', name: 'Monday', value: 9 },
+ { label: 'Tue', name: 'Tuesday', value: 14 },
+ { label: 'Wed', name: 'Wednesday', value: 11 },
+ { label: 'Thu', name: 'Thursday', value: 17 },
+ { label: 'Fri', name: 'Friday', value: 6 },
+ { label: 'Sat', name: 'Saturday', value: 0 },
+ { label: 'Sun', name: 'Sunday', value: 2 },
+];
+
+const TOTAL = WEEK.reduce((sum, day) => sum + day.value, 0);
+
+export default function BarPickerDeploys() {
+ const [day, setDay] = useState(3);
+ const picked = WEEK[day];
+ const share = Math.round((picked.value / TOTAL) * 100);
+
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Deploys
+
This week · {TOTAL}
+
+
+
+
+
+
{picked.name}
+
{picked.value}
+
+
+ {share}% of the week
+
+
+
+
`${value} deploys`}
+ />
+
+
+ );
+}
+```
+
+
+
+Use it for a handful of values where one is in focus: a week with one day picked, six sprints with the latest picked. Each value is a column of lights, lit from the bottom up to the value. Pressing a column picks it, and you show that column's figure beside the row.
+
+It behaves as a row of radio buttons, so exactly one column is always picked.
+
+A column of eight lights shows a value to the nearest eighth. For values someone has to read precisely, for more than about twelve of them, or for more than one series, use a chart with an axis.
+
+## Installation
+
+
+
+
+
+
+
+The source is copied into `components/panelui/`, where you can edit it.
+
+
+
+
+
+
+
+Import from `panelui-web/bar-picker` instead of `@/components/panelui/bar-picker`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
+
+
+
+
+
+
+
+
+
+Install the packages it uses:
+
+
+
+
+
+
+
+Copy the source into `components/panelui/bar-picker.tsx`:
+
+
+
+
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { BarPicker, type BarPickerBar } from "@/components/panelui/bar-picker"
+```
+
+```tsx
+const week: BarPickerBar[] = [
+ { label: 'Mon', name: 'Monday', value: 9 },
+ { label: 'Tue', name: 'Tuesday', value: 14 },
+ { label: 'Wed', name: 'Wednesday', value: 11 },
+]
+
+
+```
+
+## Examples
+
+### A fixed scale and a colour
+
+`max` sets what a fully lit column stands for, here 100 percent, so the columns are lit against the whole scale and not against the tallest of them. `defaultValue` picks a column to begin with and lets the component hold the choice.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { BarPicker, type BarPickerBar } from '@/components/panelui/bar-picker';
+
+/** Test coverage at the end of each sprint, as a percentage. */
+const SPRINTS: BarPickerBar[] = [
+ { label: 'S1', name: 'Sprint 1', value: 52 },
+ { label: 'S2', name: 'Sprint 2', value: 58 },
+ { label: 'S3', name: 'Sprint 3', value: 57 },
+ { label: 'S4', name: 'Sprint 4', value: 66 },
+ { label: 'S5', name: 'Sprint 5', value: 74 },
+ { label: 'S6', name: 'Sprint 6', value: 81 },
+];
+
+export default function BarPickerCoverage() {
+ return (
+ `${value} percent`}
+ className="max-w-xs"
+ />
+ );
+}
+```
+
+
+
+## API Reference
+
+### BarPicker
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `data` | `BarPickerBar[]` | — | The columns, left to right. |
+| `max` | `number` | — | The value a fully lit column stands for. Defaults to the largest in `data`. |
+| `value` | `number` | — | The index of the picked column, when the caller holds it. |
+| `defaultValue` | `number` | `0` | The index picked to begin with, when the caller does not hold it. |
+| `onValueChange` | `(index: number) => void` | — | Called with the index of a column when it is picked. |
+| `segments` | `number` | `8` | How many lights a column is made of. |
+| `color` | `BarPickerColor` | `'primary'` | Which of the theme's roles the lights are drawn in. |
+| `formatValue` | `(value: number) => string` | `(bar) => String(bar)` | Writes a value for a screen reader: "17 deploys". |
+| `label` | `string` | `'Values'` | What a screen reader calls the row. |
+
+Every part also takes the props of the element it renders, and a `className`.
+
+## Notes
+
+### Scale
+
+A column lights the share of its segments that its value is of `max`, rounded to the nearest segment. Leave `max` out to scale to the largest value in `data`, or set it when the values share a known ceiling.
+
+Any value above zero lights at least one segment. A value of zero lights none, and a value above `max` lights them all.
+
+### Segments
+
+A column has 8 segments unless `segments` says otherwise. More segments show a value more finely and need more height to stay readable. The stack is 7rem tall.
+
+### Picking
+
+The whole column is the target, so an empty column can still be picked. The picked column is half as wide again as the others, lit at full strength, and its highest light glows. The others are lit at about a quarter of the same colour.
+
+### Labels
+
+`label` is drawn under the column and should be a few characters. `name` is the long form a screen reader is given with the value: "Thursday: 17 deploys". Use `formatValue` to say the unit.
+
+### Keyboard
+
+The row is one Tab stop. Left and right move the pick and wrap at the ends, and Home and End go to the first and last column.
+
+### Motion
+
+The columns light from the bottom when the component mounts, and the picked column widens as the pick moves. With reduced motion on, both happen at once.
diff --git a/apps/docs/content/docs/web/components/command-dock.mdx b/apps/docs/content/docs/web/components/command-dock.mdx
new file mode 100644
index 00000000..13319c15
--- /dev/null
+++ b/apps/docs/content/docs/web/components/command-dock.mdx
@@ -0,0 +1,286 @@
+---
+title: Command Dock
+description: A small button that opens into a field, with the answer arriving under it a row at a time.
+status: new
+---
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { FileTextIcon, SearchIcon } from 'lucide-react';
+import { useRef, useState } from 'react';
+import { CommandDock } from '@/components/panelui/command-dock';
+
+/** What a search of some meeting notes might turn up. */
+const NOTES = [
+ { title: 'Pricing review', where: 'Notes from 3 March' },
+ { title: 'What we decided about trials', where: 'Notes from 19 February' },
+ { title: 'Questions for finance', where: 'Notes from 11 February' },
+ { title: 'Draft of the pricing page', where: 'Shared by Imani' },
+];
+
+export default function CommandDockFind() {
+ const [found, setFound] = useState([]);
+ const [busy, setBusy] = useState(false);
+ const timers = useRef([]);
+
+ // Stands in for a search that answers a row at a time.
+ const find = () => {
+ timers.current.forEach((timer) => window.clearTimeout(timer));
+ setFound([]);
+ setBusy(true);
+ timers.current = NOTES.map((note, index) =>
+ window.setTimeout(() => {
+ setFound((current) => [...current, note]);
+ if (index === NOTES.length - 1) setBusy(false);
+ }, 500 + index * 450)
+ );
+ };
+
+ return (
+
+
+
+ Search the notes
+
+
+
+
+ {found.map((note) => (
+
+
+ {note.title}
+ {note.where}
+
+ ))}
+ {busy ? Reading the notes : null}
+
+
+
+ );
+}
+```
+
+
+
+Use it for one question a reader can ask from wherever they are: search these notes, find an action, ask an assistant. It is a small button that opens into a field, and the answer arrives under the field a row at a time.
+
+It runs nothing itself. `onSubmit` is handed what was typed and the rows are whatever you render, so it works the same over a search, a model or a list you already have.
+
+It holds one field and a short list. For a conversation that goes back and forth, use a chat interface, and for a list long enough to scroll, a full search page.
+
+## Installation
+
+
+
+
+
+
+
+The source is copied into `components/panelui/`, where you can edit it.
+
+
+
+
+
+
+
+Import from `panelui-web/command-dock` instead of `@/components/panelui/command-dock`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
+
+
+
+
+
+
+
+
+
+Install the packages it uses:
+
+
+
+
+
+
+
+Copy the source into `components/panelui/command-dock.tsx`:
+
+
+
+
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { CommandDock } from "@/components/panelui/command-dock"
+```
+
+```tsx
+
+ Search the notes
+
+
+
+ {found.map((note) => (
+ {note.title}
+ ))}
+
+
+
+```
+
+## Composition
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+```
+
+- **`CommandDock.Trigger`** — The closed form: a button with your label that opens the dock.
+- **`CommandDock.Panel`** — The open form. It stays in the page while closed, folded away and inert, so opening and closing can both be seen.
+- **`CommandDock.Input`** — The field, with a send button. Enter sends too.
+- **`CommandDock.Results`** — Where the rows go. It takes no room until there is a row or the dock is busy.
+- **`CommandDock.Row`** — One row of the answer. It slides in when it is added, and `onSelect` makes it a button.
+- **`CommandDock.Status`** — Three dots and a few words, for while an answer is on its way.
+
+## Examples
+
+### Rows that can be chosen
+
+`onSelect` makes a row a button. Here the dock is held open from outside with `open`, the list is narrowed as you type through `value`, and choosing a row closes it.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { CommandDock } from '@/components/panelui/command-dock';
+
+const ACTIONS = ['Invite someone', 'Start a new notebook', 'Export this page', 'Open settings'];
+
+export default function CommandDockChoose() {
+ const [query, setQuery] = useState('');
+ const [chosen, setChosen] = useState(null);
+ const [open, setOpen] = useState(true);
+ const matches = ACTIONS.filter((action) => action.toLowerCase().includes(query.toLowerCase()));
+
+ return (
+
+
+ Do something
+
+
+
+ {matches.map((action) => (
+ {
+ setChosen(action);
+ setOpen(false);
+ }}
+ >
+ {action}
+
+ ))}
+
+
+
+
+ {chosen ? `Chosen: ${chosen}` : 'Nothing chosen yet.'}
+
+
+ );
+}
+```
+
+
+
+## API Reference
+
+### CommandDock
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `open` | `boolean` | — | Whether it is open, when the caller holds that. |
+| `defaultOpen` | `boolean` | `false` | Whether it starts open, when the caller does not. |
+| `onOpenChange` | `(open: boolean) => void` | — | Called when it opens or closes. |
+| `value` | `string` | — | What is typed in the field, when the caller holds it. |
+| `onValueChange` | `(value: string) => void` | — | Called with what is typed, each time it changes. |
+| `onSubmit` | `(value: string) => void` | — | Called with what was typed when it is sent. |
+| `busy` | `boolean` | `false` | While true, the field cannot be sent again and `CommandDock.Status` is shown. |
+| `closedWidth` | `string` | `'9rem'` | How wide it is while closed, as a CSS length. |
+
+### CommandDock.Input
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `submitLabel` | `string` | `'Send'` | What a screen reader calls the send button. |
+
+### CommandDock.Results
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `label` | `string` | — | What a screen reader calls the list of rows. |
+
+### CommandDock.Row
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `onSelect` | `() => void` | — | Called when the row is pressed. Passing this makes the row a button. |
+
+Every part also takes the props of the element it renders, and a `className`.
+
+## Notes
+
+### Sending
+
+`onSubmit` is called with the text, trimmed, when Enter is pressed or the send button is used. It is not called for an empty field or while `busy` is true.
+
+### Busy
+
+Set `busy` while an answer is on its way. The send button is disabled and the results area opens, so a `CommandDock.Status` in it has somewhere to show. Render the status yourself where you want it among the rows; the first example puts it after them.
+
+### Rows arriving
+
+A row slides in when it is added to the page. Add rows one at a time, as they come, and they read as an answer being written. Rows rendered together arrive together. Give each row a stable `key`, or a row that was already there is drawn as new.
+
+### Size
+
+Closed, the dock is `closedWidth` wide, 9rem unless you set it. It has to be a real length for the opening to be animated, so set it to fit your label. Open, it is as wide as its container up to 28rem.
+
+The dock grows downwards as rows arrive and pushes what is under it. Give its container a `min-h-*` if that should not move the page.
+
+### Opening and closing
+
+Opening, the surface widens, the label fades into the field, and the rows follow. Closing runs the other way: the rows fold up first and the surface narrows after them. With reduced motion on, both happen at once.
+
+Escape closes the dock and returns focus to its button. A press outside it closes it too. What was typed, and the rows, are kept.
+
+### Screen readers
+
+The field is a search landmark and takes `aria-label`. The results are a list that is announced politely as rows arrive, so they do not cut in on typing.
+
+### Server Components
+
+`CommandDock` is a client component. In a file that starts with `"use client"`, write its parts with a dot, as on this page. A Server Component cannot read `CommandDock.Trigger` off the import, so each part is also exported by name: `CommandDockTrigger` is `CommandDock.Trigger`, and so on for the others.
+
+```tsx
+import { CommandDock, CommandDockTrigger, CommandDockPanel, CommandDockInput, CommandDockResults, CommandDockRow, CommandDockStatus } from "@/components/panelui/command-dock"
+```
diff --git a/apps/docs/content/docs/web/components/day-grid.mdx b/apps/docs/content/docs/web/components/day-grid.mdx
new file mode 100644
index 00000000..da873c92
--- /dev/null
+++ b/apps/docs/content/docs/web/components/day-grid.mdx
@@ -0,0 +1,326 @@
+---
+title: Day Grid
+description: A run of days as a field of dots, each sized by how much happened on it.
+status: new
+---
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { DayGrid, type DayCell, type DayLevel } from '@/components/panelui/day-grid';
+
+/** Words written on each of the last 112 days, oldest first, starting on a Monday. */
+const WORDS = [
+ 0, 420, 1310, 880, 0, 0, 240, 1650, 1920, 760, 0, 310, 0, 0, 980, 1440, 2210, 1180, 640, 0, 0,
+ 1890, 2460, 1730, 920, 0, 150, 0, 2050, 1380, 0, 1120, 1960, 0, 0, 870, 2320, 1610, 1240, 2580,
+ 430, 0, 0, 0, 760, 1340, 0, 0, 280, 1470, 1980, 2240, 640, 1010, 0, 0, 2120, 940, 1760, 0, 520, 0,
+ 190, 1560, 2380, 1820, 2050, 1290, 0, 0, 0, 610, 1430, 1070, 0, 0, 0, 2290, 2510, 1940, 1680, 730,
+ 0, 360, 1210, 0, 1850, 2400, 1530, 0, 0, 990, 1720, 2160, 0, 1340, 220, 0, 2070, 2450, 1890, 2630,
+ 1160, 0, 0, 1480, 2220, 1960, 2540, 870, 410, 0,
+];
+
+/** A day's count as one of the five sizes. */
+const levelOf = (words: number): DayLevel =>
+ words === 0 ? 0 : words < 600 ? 1 : words < 1300 ? 2 : words < 2000 ? 3 : 4;
+
+const START = new Date(2026, 5, 22);
+
+const CELLS: DayCell[] = WORDS.map((words, index) => {
+ const day = new Date(START);
+ day.setDate(START.getDate() + index);
+ const date = day.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
+ return {
+ level: levelOf(words),
+ label: words === 0 ? `${date}, nothing written` : `${date}, ${words.toLocaleString('en-GB')} words`,
+ };
+});
+
+const WEEKDAYS = ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'];
+
+export default function DayGridWriting() {
+ const total = WORDS.reduce((sum, words) => sum + words, 0);
+ const written = WORDS.filter((words) => words > 0).length;
+
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Words written
+
Last 16 weeks
+
+
+
+
+
+
In total
+
+ {total.toLocaleString('en-GB')}
+
+
+
+ {written} of {WORDS.length} days
+
+
+
+ {/* A week to a column: `rows` runs the days downwards. */}
+
+
+ {WEEKDAYS.map((day, index) => (
+
+ {day}
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+```
+
+
+
+Use it to show how much happened on each day of a period: words written, commits pushed, minutes practised. Each day is a dot that grows with the amount, from a speck for an empty day to a full disc for the busiest, and the days can run along rows or down columns.
+
+It shows a pattern, not a figure. Nobody can read a count off the size of a dot, so put the number that matters beside the grid, and give each day a `label`: it is shown in a tag when the pointer is over that day.
+
+For values over two dimensions that are not days, such as hour against weekday, use a table or a chart with axes.
+
+## Installation
+
+
+
+
+
+
+
+The source is copied into `components/panelui/`, where you can edit it.
+
+
+
+
+
+
+
+Import from `panelui-web/day-grid` instead of `@/components/panelui/day-grid`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
+
+
+
+
+
+
+
+
+
+Install the packages it uses:
+
+
+
+
+
+
+
+Copy the source into `components/panelui/day-grid.tsx`:
+
+
+
+
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { DayGrid, type DayCell } from "@/components/panelui/day-grid"
+```
+
+```tsx
+const days: DayCell[] = [
+ { level: 0, label: '1 Sep, nothing written' },
+ { level: 2, label: '2 Sep, 880 words' },
+ { level: 4, label: '3 Sep, 2,210 words' },
+]
+
+
+
+```
+
+## Composition
+
+```tsx
+
+
+```
+
+- **`DayGrid.Legend`** — The five sizes in a row, quietest to busiest, with a word at each end. It is a sibling of the grid, so place it wherever the layout wants it and give it the same `color`.
+
+## Examples
+
+### Days you can press
+
+With `onValueChange` every day is a button and `value` marks the selected one. The grid is a single Tab stop, and the arrow keys move between days.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { DayGrid, type DayCell, type DayLevel } from '@/components/panelui/day-grid';
+
+/** Pull requests merged on each day of a four-week sprint. */
+const MERGED = [3, 5, 2, 7, 4, 0, 0, 6, 9, 4, 5, 8, 1, 0, 2, 4, 11, 6, 3, 0, 0, 7, 5, 8, 10, 6, 0, 1];
+
+const levelOf = (count: number): DayLevel =>
+ count === 0 ? 0 : count < 3 ? 1 : count < 6 ? 2 : count < 9 ? 3 : 4;
+
+const START = new Date(2026, 8, 7);
+
+const dateOf = (index: number) => {
+ const day = new Date(START);
+ day.setDate(START.getDate() + index);
+ return day.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' });
+};
+
+const CELLS: DayCell[] = MERGED.map((count, index) => ({
+ level: levelOf(count),
+ label: `${dateOf(index)}, ${count} merged`,
+}));
+
+export default function DayGridPickADay() {
+ const [day, setDay] = useState(16);
+
+ return (
+
+
+
+
+ {day === null ? 'Pick a day' : `${MERGED[day]} merged`}
+
+
{day === null ? '\u00a0' : dateOf(day)}
+
+
+ );
+}
+```
+
+
+
+### Colours
+
+`color` names one of the theme's roles. Use a status colour when the amount means something, such as `success` for progress towards a goal, and leave it as `primary` otherwise.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { DayGrid, type DayCell, type DayGridColor, type DayLevel } from '@/components/panelui/day-grid';
+
+/** The same fortnight in every colour, so the colour is the only difference. */
+const LEVELS: DayLevel[] = [1, 3, 4, 2, 0, 0, 2, 4, 4, 3, 1, 0, 2, 3];
+const CELLS: DayCell[] = LEVELS.map((level) => ({ level }));
+
+const COLOURS: DayGridColor[] = ['primary', 'info', 'success', 'warning', 'destructive'];
+
+export default function DayGridColours() {
+ return (
+
+ {COLOURS.map((color) => (
+
+ {color}
+
+
+ ))}
+
+ );
+}
+```
+
+
+
+## API Reference
+
+### DayGrid
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `cells` | `DayCell[]` | — | The days, in order. They fill the grid row by row. |
+| `columns` | `number` | `7` | Cells per row. Seven makes each row a week. |
+| `rows` | `number` | — | Cells per column. Giving this fills the grid column by column, and `columns` is ignored. Seven makes each column a week, which lays a long period out wide and short. |
+| `color` | `DayGridColor` | `'primary'` | Which of the theme's roles the dots are drawn in. |
+| `value` | `number \| null` | `null` | The index of the selected day, when the caller holds it. |
+| `onValueChange` | `(index: number) => void` | — | Called with the index of a day when it is pressed. Passing this makes the days buttons; without it the grid is a picture. |
+| `lens` | `boolean` | `true` | Set to `false` for dots that hold still under the pointer. |
+| `label` | `string` | `'Activity'` | What a screen reader calls the grid. |
+
+### DayGrid.Legend
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `color` | `DayGridColor` | `'primary'` | The same role the grid is drawn in. |
+| `less` | `ReactNode` | `'Less'` | The word at the quiet end of the scale. |
+| `more` | `ReactNode` | `'More'` | The word at the busy end of the scale. |
+
+Every part also takes the props of the element it renders, and a `className`.
+
+## Notes
+
+### Levels
+
+A day's `level` is a whole number from 0 to 4. Level 0 is a small grey speck and means the day was measured and nothing happened. Levels 1 to 4 are dots of growing size and strength in the chosen colour, and a level 4 dot has a faint glow.
+
+The component does not turn counts into levels, because where the steps fall depends on the data. Decide the four thresholds once and map your counts through them.
+
+### The lens
+
+Dots within about two cells of the pointer grow, the nearest most. It follows a mouse or a pen and does nothing for touch, where a finger would cover it. Pass `lens={false}` to turn it off. It is also off for anyone who has asked their system for reduced motion.
+
+### Arriving
+
+The dots grow in from nothing when the grid mounts, one diagonal after the next. With reduced motion on they are simply there.
+
+### Layout
+
+Days fill the grid row by row and stay round at any width. `columns` defaults to 7, which makes each row a week and the grid tall. For a strip that runs along one line, set `columns` to the number of days.
+
+Give `rows` and the days run down each column, `rows` to a column, for as many columns as it takes. `rows={7}` makes each column a week, which lays a long period out wide and short.
+
+The grid takes the width of its container. Set a `max-w-*` class on it or on its parent to stop the dots growing too large.
+
+### Labels
+
+`label` on a day is its tag and what a screen reader says for it, so write the date and the figure: "14 May, 6 commits". A day without a label has no tag, and a pressable one is announced by its position and level.
+
+The grid as a whole is announced with its `label` and how many of the days have something on them.
+
+### Keyboard
+
+When the days are buttons, Tab moves to the selected day, or the first. The arrow keys move to the cell beside, above or below, whichever way the days run, and Home and End go to the first and last day.
+
+### Server Components
+
+`DayGrid` is a client component. In a file that starts with `"use client"`, write its parts with a dot, as on this page. A Server Component cannot read `DayGrid.Legend` off the import, so each part is also exported by name: `DayGridLegend` is `DayGrid.Legend`.
+
+```tsx
+import { DayGrid, DayGridLegend } from "@/components/panelui/day-grid"
+```
diff --git a/apps/docs/content/docs/web/components/feedback.mdx b/apps/docs/content/docs/web/components/feedback.mdx
new file mode 100644
index 00000000..b1895acd
--- /dev/null
+++ b/apps/docs/content/docs/web/components/feedback.mdx
@@ -0,0 +1,592 @@
+---
+title: Feedback
+description: A dialog with a well to write in and two actions in the band around it.
+status: new
+---
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { Feedback } from '@/components/panelui/feedback';
+
+export default function FeedbackInThePage() {
+ const [message, setMessage] = useState('');
+ const [sent, setSent] = useState(false);
+
+ // Inline, there is nothing to close. Cancel and the ✕ report here instead,
+ // and this page takes that to mean "start again".
+ const reset = () => {
+ setMessage('');
+ setSent(false);
+ };
+
+ return (
+
+
+
+ {sent ? (
+ <>
+ Thank you
+
+ We read every message. If yours needs a reply, you will have one
+ within a working day.
+
+ >
+ ) : (
+ <>
+ What should we fix first?
+
+
+ >
+ )}
+
+
+ {sent ? (
+ Write another
+ ) : (
+ <>
+ Clear
+ setSent(true)} />
+ >
+ )}
+
+
+
+ );
+}
+```
+
+
+
+Use it to ask for a sentence: what went wrong, what to fix first, why somebody gave the score they did. It is a dialog whose body is a place to write, with Cancel and Submit in the band around it.
+
+`Submit` cannot be pressed while the field is empty, and it does not close the dialog. You close it once the message has been sent, so a send that fails still has somewhere to say so.
+
+For a yes-or-no confirmation use a plain dialog. For a form with several fields, build the form.
+
+## Installation
+
+
+
+
+
+
+
+The source is copied into `components/panelui/`, where you can edit it.
+
+
+
+
+
+
+
+Import from `panelui-web/feedback` instead of `@/components/panelui/feedback`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
+
+
+
+
+
+
+
+
+
+Install the packages it uses:
+
+
+
+
+
+
+
+Copy the source into `components/panelui/feedback.tsx`:
+
+
+
+
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { Feedback } from "@/components/panelui/feedback"
+```
+
+```tsx
+
+ Send feedback
+
+
+ What should we fix first?
+
+
+
+
+
+ send(message)} />
+
+
+
+```
+
+## Composition
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- **`Feedback.Trigger`** — What opens the dialog. A plain button, or your own through `render`.
+- **`Feedback.Content`** — The shell: the darker band and everything set in it. `inline` draws it in the page instead of over it.
+- **`Feedback.Panel`** — The well. The title, the close button and the field go here, along with anything else the question needs.
+- **`Feedback.Title`** — The question. In a dialog it is also the dialog's accessible name.
+- **`Feedback.Close`** — The ✕ in the corner of the panel.
+- **`Feedback.Field`** — The text area. It has no border of its own, because it is already inside a well.
+- **`Feedback.Footer`** — The row the two actions sit in.
+- **`Feedback.Cancel`** — Closes the dialog. Call `preventDefault` in `onClick` to do something else, such as going back a step.
+- **`Feedback.Submit`** — Calls `onSubmit` with the message. Disabled while the field is empty unless you pass `disabled` yourself.
+
+## Examples
+
+### In a dialog
+
+The usual shape. `Feedback.Trigger` opens it, and `render` makes your own button the trigger. Hold `open` yourself so you can close the dialog when the message has gone.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Feedback } from '@/components/panelui/feedback';
+
+export default function FeedbackDialog() {
+ const [open, setOpen] = useState(false);
+ const [message, setMessage] = useState('');
+
+ return (
+
+ }>Send feedback
+
+
+ What should we fix first?
+
+
+
+
+
+ {
+ // Send `message` here, then close and clear for next time.
+ setOpen(false);
+ setMessage('');
+ }}
+ />
+
+
+
+ );
+}
+```
+
+
+
+### A send that fails
+
+Because `Submit` leaves the dialog open, a failed request can say so in the panel, with the message still in the field. This demo fails the first time and succeeds on the retry.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Feedback } from '@/components/panelui/feedback';
+
+/** Stands in for a request. It fails once, so there is a failure to look at. */
+function send(attempt: number) {
+ return new Promise((resolve, reject) => {
+ setTimeout(() => (attempt === 0 ? reject(new Error('offline')) : resolve()), 900);
+ });
+}
+
+export default function FeedbackFailed() {
+ const [open, setOpen] = useState(false);
+ const [message, setMessage] = useState('The export button does nothing on Safari.');
+ const [status, setStatus] = useState<'idle' | 'sending' | 'failed'>('idle');
+ const [attempt, setAttempt] = useState(0);
+
+ const submit = async () => {
+ setStatus('sending');
+ try {
+ await send(attempt);
+ setOpen(false);
+ setMessage('');
+ setStatus('idle');
+ setAttempt(0);
+ } catch {
+ // The dialog is still open, so the failure has somewhere to be said
+ // and the message is still in the field to send again.
+ setStatus('failed');
+ setAttempt((current) => current + 1);
+ }
+ };
+
+ return (
+
+ }>Report a problem
+
+
+ What went wrong?
+
+
+ {status === 'failed' ? (
+
+ That did not send. Your message is still here.
+
+ ) : null}
+
+
+
+
+ {status === 'sending' ? 'Sending…' : status === 'failed' ? 'Try again' : 'Submit'}
+
+
+
+
+ );
+}
+```
+
+
+
+### Tags over the field
+
+Anything can go in the panel above the field. Here a tag is enough to send on its own, so `Submit` is given its own `disabled` and the field becomes optional.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { Feedback } from '@/components/panelui/feedback';
+
+const TAGS = ['Bug', 'Slow', 'Confusing', 'Missing feature', 'Praise'];
+
+export default function FeedbackTags() {
+ const [open, setOpen] = useState(false);
+ const [message, setMessage] = useState('');
+ const [tags, setTags] = useState([]);
+
+ const toggle = (tag: string) =>
+ setTags((current) =>
+ current.includes(tag) ? current.filter((item) => item !== tag) : [...current, tag]
+ );
+
+ // A tag on its own says something, so it is enough to send.
+ const nothingToSend = tags.length === 0 && message.trim().length === 0;
+
+ return (
+
+ }>Send feedback
+
+
+ What is this about?
+
+
+ {TAGS.map((tag) => {
+ const picked = tags.includes(tag);
+ return (
+ toggle(tag)}
+ className={cn(
+ 'h-8 cursor-pointer rounded-full px-3 text-sm font-medium transition-colors',
+ picked
+ ? 'bg-primary text-primary-foreground'
+ : 'bg-foreground/10 text-muted-foreground hover:text-foreground'
+ )}
+ >
+ {tag}
+
+ );
+ })}
+
+
+
+
+
+ {
+ setOpen(false);
+ setMessage('');
+ setTags([]);
+ }}
+ />
+
+
+
+ );
+}
+```
+
+
+
+### Score first, words second
+
+Two steps in one dialog. Asking for the score first gives the sentence something to be about. Back is a `Feedback.Cancel` whose `onClick` calls `preventDefault`, which stops it closing.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { Feedback } from '@/components/panelui/feedback';
+
+const SCORES = [1, 2, 3, 4, 5];
+
+export default function FeedbackScore() {
+ const [open, setOpen] = useState(false);
+ const [score, setScore] = useState(null);
+ const [message, setMessage] = useState('');
+
+ const close = (next: boolean) => {
+ setOpen(next);
+ if (!next) {
+ setScore(null);
+ setMessage('');
+ }
+ };
+
+ return (
+
+ }>Rate the new editor
+
+ {score === null ? (
+ <>
+
+ How is the new editor?
+
+
+
+ {SCORES.map((value) => (
+ setScore(value)}
+ className="flex size-12 cursor-pointer items-center justify-center rounded-full bg-foreground/10 text-lg font-semibold transition-colors hover:bg-primary hover:text-primary-foreground"
+ >
+ {value}
+
+ ))}
+
+
+ Worse
+ Much better
+
+
+
+
+ Not now
+
+ >
+ ) : (
+ <>
+
+
+ What made it a = 4 && 'text-emerald-500')}>{score} ?
+
+
+
+
+
+ {/* Back, not Cancel: preventDefault keeps the dialog open. */}
+ {
+ event.preventDefault();
+ setScore(null);
+ }}
+ >
+ Back
+
+ close(false)} />
+
+ >
+ )}
+
+
+ );
+}
+```
+
+
+
+### Keeping a draft
+
+Pass `value` and `onValueChange` to hold the message yourself. It survives the dialog being closed, and you can check it as it is typed. This one needs twenty characters before it can be sent.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Feedback } from '@/components/panelui/feedback';
+
+/** Short answers are rarely useful ones. */
+const ENOUGH = 20;
+
+export default function FeedbackDraft() {
+ const [open, setOpen] = useState(false);
+ // Held here, so it is still there when the dialog is opened again.
+ const [draft, setDraft] = useState('');
+ const written = draft.trim().length;
+
+ return (
+
+
+ }>
+ {written > 0 ? 'Continue your message' : 'Send feedback'}
+
+
+
+ What would you change?
+
+
+
+ {written < ENOUGH ? `${ENOUGH - written} more characters` : 'Ready to send'}
+
+
+
+ Keep for later
+ {
+ setOpen(false);
+ setDraft('');
+ }}
+ />
+
+
+
+
+ {written > 0 ? `Draft kept: ${written} characters` : 'No draft'}
+
+
+ );
+}
+```
+
+
+
+## API Reference
+
+### Feedback
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `open` | `boolean` | — | Whether the dialog is open, when the caller holds that. |
+| `onOpenChange` | `(open: boolean) => void` | — | Called when the dialog asks to open or close. |
+| `defaultOpen` | `boolean` | `false` | Whether it starts open, when the caller does not hold it. |
+| `value` | `string` | — | The message, when the caller holds it. Leave unset to let the field keep it. |
+| `defaultValue` | `string` | `''` | The message to start with, when the caller does not hold it. |
+| `onValueChange` | `(value: string) => void` | — | Called with the message each time it changes. |
+| `dismissible` | `boolean` | `true` | Whether a click outside or the Escape key closes the dialog. Turn it off for a message that would be lost by a stray click; Cancel and the close button still work. |
+
+### Feedback.Trigger
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `render` | `ReactElement` | — | Your own element to be the trigger, such as a styled button. It is given the click handler and the attributes that tie it to the dialog. |
+
+### Feedback.Content
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `inline` | `boolean` | `false` | Draw it in the page instead of over it: no backdrop, no trigger, always there. For a feedback box that is part of the page, such as the end of an article. `open` is ignored, and Cancel and the close button only report through `onOpenChange`. |
+| `blur` | `boolean` | `false` | Frost the page behind the dialog instead of only dimming it. |
+
+### Feedback.Close
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `label` | `string` | — | How the button announces itself to a screen reader. |
+
+### Feedback.Field
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `value` | `string` | — | The message. Leave unset to let the dialog hold it. |
+| `onValueChange` | `(value: string) => void` | — | Called with the message each time it changes. |
+| `minHeight` | `number` | `200` | Room to write in, in pixels, before the field starts to grow. |
+| `maxHeight` | `number` | `360` | How tall the field may grow, in pixels, before the area round it scrolls. |
+
+### Feedback.Submit
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `onSubmit` | `(value: string) => void` | — | Called with the message. The dialog stays open: close it yourself once the message has been sent, so a failure still has somewhere to be shown. |
+
+Every part also takes the props of the element it renders, and a `className`.
+
+## Notes
+
+### In the page
+
+`` draws the same shell in the page, with no backdrop and nothing to open. Cancel and the close button have nothing to close there, so they only call `onOpenChange(false)`. Use that to clear the field or to hide the box.
+
+### Closing
+
+A click outside the dialog or the Escape key closes it. Pass `dismissible={false}` to `Feedback` to turn both off, for a message that a stray click should not throw away. Cancel and the close button still work.
+
+### Keyboard
+
+Enter adds a new line. Command+Enter on a Mac, or Control+Enter elsewhere, sends the message, as long as `Submit` is enabled. Focus is kept inside the dialog while it is open and returns to the trigger when it closes.
+
+### The empty state
+
+While `Submit` is disabled it drops its fill, where most buttons would only dim. A dimmed accent button still looks like the one to press. Pass `disabled` to decide for yourself when the message can be sent: `disabled={false}` allows an empty one.
+
+### What it is built on
+
+The dialog is the Base UI dialog, which the package depends on directly. It does not use your project's own dialog component, so it looks the same whichever one you have.
+
+### Server Components
+
+`Feedback` is a client component. In a file that starts with `"use client"`, write its parts with a dot, as on this page. A Server Component cannot read `Feedback.Trigger` off the import, so each part is also exported by name: `FeedbackTrigger` is `Feedback.Trigger`, and so on for the others.
+
+```tsx
+import { Feedback, FeedbackTrigger, FeedbackContent, FeedbackPanel, FeedbackTitle, FeedbackClose, FeedbackField, FeedbackFooter, FeedbackCancel, FeedbackSubmit } from "@/components/panelui/feedback"
+```
diff --git a/apps/docs/content/docs/web/components/flow.mdx b/apps/docs/content/docs/web/components/flow.mdx
new file mode 100644
index 00000000..7a1ed670
--- /dev/null
+++ b/apps/docs/content/docs/web/components/flow.mdx
@@ -0,0 +1,615 @@
+---
+title: Flow
+description: A canvas of nodes joined by edges, that you pan, zoom and rearrange.
+status: new
+full: true
+---
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useCallback, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Flow } from '@/components/panelui/flow';
+
+/** An article on its way to being published. */
+const STAGES = [
+ { id: 'draft', name: 'Draft', detail: '1,840 words' },
+ { id: 'edit', name: 'Edit', detail: 'Two rounds' },
+ { id: 'check', name: 'Fact check', detail: '14 claims' },
+ { id: 'legal', name: 'Legal read', detail: 'One quote to clear' },
+ { id: 'schedule', name: 'Schedule', detail: 'Thursday, 09:00' },
+ { id: 'publish', name: 'Publish', detail: 'Site and newsletter' },
+];
+
+const TAG = {
+ done: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-(--success)',
+ live: 'bg-[color-mix(in_oklab,var(--info)_14%,transparent)] text-(--info)',
+ waiting: 'bg-muted text-muted-foreground',
+};
+
+/**
+ * Whether the canvas has room to lay the graph out across. It is the canvas
+ * that is measured and not the window: in a page with a sidebar the two are
+ * far apart.
+ */
+function useRoom(least: number) {
+ const [wide, setWide] = useState(true);
+ const watch = useCallback(
+ (node: HTMLElement | null) => {
+ if (!node) return;
+ const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= least));
+ observer.observe(node);
+ return () => observer.disconnect();
+ },
+ [least]
+ );
+ return [wide, watch] as const;
+}
+
+export default function FlowPipeline() {
+ // The stage being worked on. Everything before it is done.
+ const [at, setAt] = useState(1);
+ const [wide, watch] = useRoom(720);
+ const columns = wide ? 3 : 2;
+
+ // The stages run along a row and back along the next, like a snake.
+ const place = (index: number) => {
+ const row = Math.floor(index / columns);
+ const along = index % columns;
+ const column = row % 2 ? columns - 1 - along : along;
+ return { x: column * (wide ? 340 : 216), y: row * (wide ? 230 : 160) };
+ };
+
+ return (
+
+
+
+ {STAGES.map((stage, index) => {
+ const state = index < at ? 'done' : index === at ? 'live' : 'waiting';
+ return (
+
+
+ Stage {index + 1}
+
+ {state === 'done' ? 'Done' : state === 'live' ? 'Now' : 'Waiting'}
+
+
+
+
{stage.name}
+
{stage.detail}
+
+
+ );
+ })}
+ {STAGES.slice(1).map((stage, index) => (
+
+ ))}
+ {/* Fewer buttons where the graph needs the room. */}
+
+
+
+
+ setAt(at - 1)}>
+ Back a stage
+
+ setAt(at + 1)}>
+ Advance
+
+
+
+ );
+}
+```
+
+
+
+Use it to show how things are connected and let somebody move them about: the stages of a pipeline, an automation, the parts of a system. Nodes are boxes with your own contents, edges join them, and the canvas is panned and zoomed.
+
+It has the same parts and props as the app library's Flow, so a graph written for one reads the same in the other.
+
+It lays nothing out for you. Every node is given a position, so for a graph whose shape comes from data you need to work the positions out first. For steps in a fixed order with nothing to rearrange, a list of steps is the simpler thing.
+
+## Installation
+
+
+
+
+
+
+
+The source is copied into `components/panelui/`, where you can edit it.
+
+
+
+
+
+
+
+Import from `panelui-web/flow` instead of `@/components/panelui/flow`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
+
+
+
+
+
+
+
+
+
+Install the packages it uses:
+
+
+
+
+
+
+
+Copy the source into `components/panelui/flow/`, one file for each below:
+
+
+
+
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { Flow } from "@/components/panelui/flow"
+```
+
+```tsx
+
+
+ Draft
+ Edit
+
+
+
+```
+
+## Composition
+
+```tsx
+
+
+
+ …
+
+
+
+
+
+
+
+```
+
+- **`Flow.Background`** — The pattern behind the graph: dots, lines or crosses. It moves and scales with the graph.
+- **`Flow.Node`** — A box on the graph. `id` is what edges call it, `position` is where it starts, and its children are yours.
+- **`Flow.Handle`** — A dot on a node's edge that a connection can be drawn from or dropped on. Give it an `id` to join an edge to it by name.
+- **`Flow.Edge`** — A line from one node to another: a curve, a routed line or a straight one, optionally dashed, marching and with an arrowhead.
+- **`Flow.Group`** — A dashed box with a label, round things that belong together. It is dragged as one and an edge can be joined to it.
+- **`Flow.Controls`** — Buttons to zoom, fit everything in view and lock the canvas.
+- **`Flow.MiniMap`** — The whole graph, small, with the part in view marked on it.
+
+## Examples
+
+### Wiring it up
+
+A `Flow.Handle` is somewhere to start a connection. Drag from one to another node and `onConnect` is told which two; the edges are yours to keep. `isValidConnection` refuses the ones you do not want, here a second wire between the same two steps.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useCallback, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Flow, type FlowConnection } from '@/components/panelui/flow';
+
+/** Each step, and where it sits: across a wide screen, down a narrow one. */
+const NODES = [
+ {
+ id: 'form',
+ name: 'Sign-up form',
+ across: { x: 0, y: 90 },
+ down: { x: 0, y: 0 },
+ },
+ {
+ id: 'verify',
+ name: 'Verify email',
+ across: { x: 320, y: 0 },
+ down: { x: 150, y: 80 },
+ },
+ {
+ id: 'welcome',
+ name: 'Welcome message',
+ across: { x: 320, y: 180 },
+ down: { x: 0, y: 160 },
+ },
+ {
+ id: 'crm',
+ name: 'Add to CRM',
+ across: { x: 680, y: 90 },
+ down: { x: 150, y: 240 },
+ },
+];
+
+/**
+ * Whether the canvas has room to lay the graph out across. It is the canvas
+ * that is measured and not the window: in a page with a sidebar the two are
+ * far apart.
+ */
+function useRoom(least: number) {
+ const [wide, setWide] = useState(true);
+ const watch = useCallback(
+ (node: HTMLElement | null) => {
+ if (!node) return;
+ const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= least));
+ observer.observe(node);
+ return () => observer.disconnect();
+ },
+ [least]
+ );
+ return [wide, watch] as const;
+}
+
+const START: FlowConnection[] = [{ source: 'form', target: 'verify' }];
+
+export default function FlowWiring() {
+ const [wires, setWires] = useState(START);
+ const [wide, watch] = useRoom(720);
+ // A wire leaves one face of a step and arrives at the opposite one.
+ const out = wide ? 'right' : 'bottom';
+ const into = wide ? 'left' : 'top';
+
+ const joined = (a: FlowConnection, b: FlowConnection) =>
+ a.source === b.source && a.target === b.target;
+
+ return (
+
+
!wires.some((wire) => joined(wire, connection))}
+ onConnect={(connection) => setWires((current) => [...current, connection])}
+ >
+
+ {NODES.map((node) => (
+
+ {node.name}
+
+
+
+ ))}
+ {wires.map((wire) => (
+
+ ))}
+
+
+
+
+
+ {wide ? 'Drag from a dot on one step to another step to join them. ' : null}
+ {wires.length} joined.
+
+
setWires(START)}>
+ Reset
+
+
+
+ );
+}
+```
+
+
+
+### Groups and a minimap
+
+`Flow.Group` holds ordinary elements and moves as one. The edges are joined to the groups by their `id`. `Flow.MiniMap` shows where the view is on the whole graph.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import {
+ ArchiveIcon,
+ DatabaseIcon,
+ GlobeIcon,
+ HardDriveIcon,
+ ServerIcon,
+ ShieldCheckIcon,
+} from 'lucide-react';
+import { useCallback, useState, type ReactNode } from 'react';
+import { Flow } from '@/components/panelui/flow';
+
+function Service({ icon, name, detail }: { icon: ReactNode; name: string; detail: string }) {
+ return (
+
+ );
+}
+
+/**
+ * Whether the canvas has room to lay the graph out across. It is the canvas
+ * that is measured and not the window: in a page with a sidebar the two are
+ * far apart.
+ */
+function useRoom(least: number) {
+ const [wide, setWide] = useState(true);
+ const watch = useCallback(
+ (node: HTMLElement | null) => {
+ if (!node) return;
+ const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= least));
+ observer.observe(node);
+ return () => observer.disconnect();
+ },
+ [least]
+ );
+ return [wide, watch] as const;
+}
+
+export default function FlowGroups() {
+ const [wide, watch] = useRoom(720);
+ // Side by side where there is room, and one under the other where there is not.
+ const from = wide ? 'right' : 'bottom';
+ const to = wide ? 'left' : 'top';
+
+ return (
+ // Taller where the groups are stacked, so they are not shrunk to fit.
+
+
+
+
+ } name="Cache" detail="96 locations" />
+ } name="Firewall" detail="Blocking 0.3%" />
+
+
+ } name="Application" detail="Six instances" />
+ } name="Database" detail="One primary, two copies" />
+
+
+ } name="Uploads" detail="2.1 TB" />
+ } name="Backups" detail="Every six hours" />
+
+
+
+
+ {/* The map is for a graph too big to see at once, which on a narrow screen this is not. */}
+ {wide ? : null}
+
+
+ );
+}
+```
+
+
+
+## API Reference
+
+### Flow
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `defaultViewport` | `FlowViewport` | — | Where the canvas looks to begin with. |
+| `minZoom` | `number` | `0.3` | The furthest out it can be zoomed. |
+| `maxZoom` | `number` | `2.5` | The furthest in it can be zoomed. |
+| `panOnDrag` | `boolean` | `true` | Whether dragging the pane pans it. |
+| `zoomOnPinch` | `boolean` | `true` | Whether a pinch, or Control with the wheel, zooms it. |
+| `fitViewOnMount` | `boolean` | `false` | Fits every node into view once they have been measured. |
+| `fitViewPadding` | `number` | `48` | The room left round the nodes when fitting them, in pixels. |
+| `onViewportChange` | `(viewport: FlowViewport) => void` | — | Called as the canvas is panned or zoomed. |
+| `onNodeDragEnd` | `(id: string, position: FlowNodePosition) => void` | — | Called with a node's new position when a drag or a key press has moved it. |
+| `onConnect` | `(connection: FlowConnection) => void` | — | Called when a connection drawn from a handle lands on another node. |
+| `isValidConnection` | `(connection: FlowConnection) => boolean` | — | Decides whether a connection may land. Without it, any two different nodes may be joined. |
+| `label` | `string` | `'Diagram'` | What a screen reader calls the canvas. |
+
+### Flow.Background
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `variant` | `'dots' \| 'lines' \| 'cross' \| 'none'` | `'bezier'` | Dots, a grid of lines, small crosses, or nothing. |
+| `gap` | `number` | `20` | The distance from one mark to the next, in pixels at a zoom of 1. |
+| `size` | `number` | — | How big a mark is: a dot's radius, a line's thickness, half a cross's arm. |
+| `className` | `string` | — | |
+
+### Flow.Node
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `id` | `string` | — | What edges and `onNodeDragEnd` call this node. |
+| `position` | `FlowNodePosition` | — | Where its top left corner is, on the graph. |
+| `draggable` | `boolean` | `true` | Whether it can be dragged and moved with the arrow keys. |
+| `selected` | `boolean` | `false` | Draws it as the one that has been picked. |
+| `onPress` | `() => void` | — | Called when it is pressed without being dragged, or with Enter. |
+| `onDelete` | `() => void` | — | Called when Delete or Backspace is pressed on it. |
+| `moveStep` | `number` | — | How far one arrow key press moves it, in pixels. Shift moves four times as far. |
+
+### Flow.Group
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `label` | `ReactNode` | — | Written in the corner of the group. |
+
+### Flow.Handle
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `id` | `string` | — | A name for this handle, so an edge can be joined to it and not only to the node. |
+| `position` | `FlowSide` | `'bottom-right'` | Which face of the node it sits on. |
+| `offset` | `number` | — | How far along that face, from 0 to 1. |
+
+### Flow.Edge
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `from` | `FlowEndpointReference` | — | Where it leaves from: a node's `id`, or `{ node, handle }`. |
+| `to` | `FlowEndpointReference` | — | Where it arrives. |
+| `variant` | `FlowEdgeVariant` | `'bezier'` | A curve, a rounded right-angled route, a square one, or a straight line. |
+| `animated` | `boolean` | `false` | Sets the dashes marching towards the target. |
+| `dashed` | `boolean` | `false` | Draws it dashed. |
+| `color` | `FlowColor` | — | One of the theme's roles. Without it the edge is a quiet grey. |
+| `width` | `number` | `2` | How thick the line is, in pixels at a zoom of 1. |
+| `arrow` | `boolean` | `false` | Draws an arrowhead where it arrives. |
+| `fromSide` | `FlowSide` | — | The face it leaves from. Worked out from where the two nodes are when left out. |
+| `toSide` | `FlowSide` | — | The face it arrives at. |
+| `radius` | `number` | `8` | How round the corners of a `smoothstep` route are. |
+| `gap` | `number` | `20` | How far a routed edge runs straight out of a node before it turns. |
+| `curvature` | `number` | `0.25` | How far a `bezier` bulges when its target is behind its source. |
+
+### Flow.Controls
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `zoom` | `boolean` | `true` | Shows the two zoom buttons. |
+| `fit` | `boolean` | `true` | Shows the button that fits every node into view. |
+| `lock` | `boolean` | `true` | Shows the button that locks the canvas against panning, zooming and dragging. |
+| `step` | `number` | `1.3` | How much one press of a zoom button zooms. |
+| `position` | `FlowCorner` | `'bottom-right'` | Which corner of the canvas they sit in. |
+
+### Flow.MiniMap
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `width` | `number` | `120` | How wide the map is, in pixels. |
+| `height` | `number` | `80` | How tall the map is, in pixels. |
+| `position` | `FlowCorner` | `'bottom-left'` | Which corner of the canvas it sits in. |
+
+Every part also takes the props of the element it renders, and a `className`.
+
+## Notes
+
+### Size
+
+The canvas is 24rem tall and as wide as its container. Set a height with `className`.
+
+It is drawn as a bordered, rounded box. Inside a frame that already has an edge, such as a card or a panel, give it the frame's height and take its own edge off: `className="h-full rounded-none border-0"`. The examples on this page do that.
+
+`fitViewOnMount` fits the graph once. Where the room for the graph changes a great deal, as between a phone and a desktop, lay the nodes out for each and give the canvas a `key` that changes with the layout, so it is fitted again.
+
+### Positions
+
+`position` is the node's top left corner on the graph, in pixels at a zoom of 1. The canvas holds a node's position after that: a drag moves it, and `onNodeDragEnd` reports where it stopped. To move a node yourself, keep its position in state and pass the new one.
+
+`fitViewOnMount` zooms and pans so every node is in view once they have been measured. It never zooms in past 1.
+
+### Edges
+
+`from` and `to` are node ids, or `{ node, handle }` to join a handle by its `id`. The faces an edge uses are worked out from where the two nodes are, and change as one is dragged past the other; `fromSide` and `toSide` fix them.
+
+`variant` is `bezier`, `smoothstep`, `step` or `straight`. `animated` sets dashes marching towards the target, and stops for anyone who has asked for reduced motion. `color` names one of the theme's roles; without it an edge is a quiet grey.
+
+### Panning and zooming
+
+Drag the empty canvas to pan. Zoom with a pinch, or with Control or Command and the wheel. The plain wheel is left to the page, so a diagram in a long page does not trap its scroll. `Flow.Controls` has buttons for the same things, and a lock that stops all of it.
+
+### Keyboard
+
+The canvas and every node are Tab stops. On the canvas the arrow keys pan, plus and minus zoom and 0 fits everything. On a node the arrow keys move it by `moveStep`, four times as far with Shift, Enter presses it and Delete calls `onDelete`.
+
+A connection can only be drawn with a pointer. Where joining nodes matters to everyone, give them another way to do it, such as a menu on the node.
+
+### Inside a node
+
+A node is a card by default; pass `className` to change it. Buttons, links and fields inside a node work as usual and do not start a drag. Put `data-no-drag` on anything else that should not.
+
+### Groups
+
+A group's children are ordinary elements, laid out however you like inside it. They are not nodes: an edge is joined to the group, not to something in it.
+
+### Server Components
+
+`Flow` is a client component. In a file that starts with `"use client"`, write its parts with a dot, as on this page. A Server Component cannot read `Flow.Background` off the import, so each part is also exported by name: `FlowBackground` is `Flow.Background`, and so on for the others.
+
+```tsx
+import { Flow, FlowBackground, FlowNode, FlowHandle, FlowEdge, FlowGroup, FlowControls, FlowMiniMap } from "@/components/panelui/flow"
+```
diff --git a/apps/docs/content/docs/web/components/index.mdx b/apps/docs/content/docs/web/components/index.mdx
new file mode 100644
index 00000000..19393322
--- /dev/null
+++ b/apps/docs/content/docs/web/components/index.mdx
@@ -0,0 +1,10 @@
+---
+title: Components
+description: The 11 components in the web collection, each one running.
+full: true
+---
+
+Every card is the component itself, running. Open one for its page, where it can be used, and
+where its source is under it.
+
+
diff --git a/apps/docs/content/docs/web/components/kpi.mdx b/apps/docs/content/docs/web/components/kpi.mdx
new file mode 100644
index 00000000..d1c6e4cc
--- /dev/null
+++ b/apps/docs/content/docs/web/components/kpi.mdx
@@ -0,0 +1,391 @@
+---
+title: KPI
+description: "One number and what it is doing: its change, its shape over time, its progress to a target."
+status: new
+---
+
+
+
+```tsx lineNumbers
+import { Kpi } from '@/components/panelui/kpi';
+
+/** Each figure over the last thirty days, oldest first. */
+const SUBSCRIBERS = [152, 154, 153, 157, 161, 160, 164, 169, 168, 172, 176, 175, 179, 182];
+const UNSUBSCRIBES = [14, 13, 13, 12, 12, 11, 12, 10, 10, 9, 9, 8, 8, 7];
+const OPENS = [41, 42, 44, 43, 45, 44, 46, 45, 47, 46, 46, 48, 47, 47];
+
+export default function KpiOverview() {
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Newsletter
+
Last 30 days
+
+
+
+
+
+
+
+ Subscribers
+ 18,240
+
+
+
+
+
+
+
+
+ Unsubscribe rate
+ 0.8%
+
+
+
+
+
+
+
+
+ Open rate
+ 47.3%
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+
+
+Use it for a number someone checks, with whatever says how it is doing: the change since last time, the shape of it over a period, how far it is towards a target. It has the same parts as the app library's KPI, so a metric reads the same on both.
+
+The number comes first. The trend, the chart and the bar are all small on purpose, and none of them can be read to a value.
+
+For a chart someone needs to read values from, use a chart with an axis. For one bare figure with a label, a `Kpi.Title` and a `Kpi.Value` are enough.
+
+## Installation
+
+
+
+
+
+
+
+The source is copied into `components/panelui/`, where you can edit it.
+
+
+
+
+
+
+
+Import from `panelui-web/kpi` instead of `@/components/panelui/kpi`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
+
+
+
+
+
+
+
+
+
+Install the packages it uses:
+
+
+
+
+
+
+
+Copy the source into `components/panelui/kpi.tsx`:
+
+
+
+
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { Kpi } from "@/components/panelui/kpi"
+```
+
+```tsx
+
+ Subscribers
+ 18,240
+
+
+```
+
+## Composition
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- **`Kpi.Header`** — The row over the number. A title in it takes the room between the icon and the actions.
+- **`Kpi.Icon`** — A small tile for an icon. `tone` colours it as good or bad news.
+- **`Kpi.Title`** — What the number measures.
+- **`Kpi.Actions`** — Whatever belongs at the end of the header: a period, a trend badge, a button.
+- **`Kpi.Content`** — The row the number is in. `layout="inline"` is for a chart beside it.
+- **`Kpi.Stat`** — Title, value and trend stacked as one block, for a row with a chart at its other end.
+- **`Kpi.Value`** — The number.
+- **`Kpi.Trend`** — The change, given as a number. It writes the sign and picks the colour.
+- **`Kpi.Chart`** — A sparkline of a list of numbers. `inline` gives it a fixed box, `filled` a wash under the line.
+- **`Kpi.Progress`** — A bar towards a target, with a label and the percentage over it.
+- **`Kpi.Footer`** — A quiet line under everything.
+- **`Kpi.Separator`** — A rule between two parts of one card.
+- **`Kpi.Group`** — Several metrics as one panel, side by side or stacked, with a rule between each and the next.
+
+## Examples
+
+### A card of its own
+
+`surface` draws the metric as a card. The trend is a badge in the header, and the chart runs the width of the card under the number with a wash beneath the line.
+
+
+
+```tsx lineNumbers
+import { UsersIcon } from 'lucide-react';
+import { Kpi } from '@/components/panelui/kpi';
+
+const READERS = [310, 318, 305, 322, 340, 336, 351, 348, 366, 372, 369, 388, 401, 412];
+
+export default function KpiCard() {
+ return (
+
+
+
+
+
+ Weekly readers
+
+
+
+
+ 4,120
+
+ Counted every Monday
+
+ );
+}
+```
+
+
+
+### Towards a target
+
+`Kpi.Progress` takes a value and what it is out of. `Kpi.Icon` takes a `tone`, so the tile can agree with the news beside it.
+
+
+
+```tsx lineNumbers
+import { BanknoteIcon, TargetIcon } from 'lucide-react';
+import { Kpi } from '@/components/panelui/kpi';
+
+export default function KpiTargets() {
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Targets
+
This quarter
+
+
+
+
+
+
+
+
+
+ Paid members
+
+
+
+
+ 1,463
+
+ 38 days left in the quarter
+
+
+
+
+
+
+ Sponsorship
+
+
+
+
+ €9,800
+
+
+
+
+
+ );
+}
+```
+
+
+
+### Several as one panel
+
+`Kpi.Group` puts a rule between metrics and shares the width between them. Without a header each metric is a title, a value and a trend.
+
+
+
+```tsx lineNumbers
+import { Kpi } from '@/components/panelui/kpi';
+
+export default function KpiPanel() {
+ return (
+
+
+
+ Sent
+ 112
+
+
+
+ Replies
+ 386
+
+
+
+ Bounces
+ 0.6%
+
+
+
+
+ );
+}
+```
+
+
+
+## API Reference
+
+### Kpi
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `surface` | `boolean` | — | Draws the metric as a card of its own. Leave it off inside a frame or a group. |
+
+### Kpi.Icon
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `tone` | `KpiTone` | `neutral` | How the tile is coloured: by good or bad news, or left neutral. |
+
+### Kpi.Content
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `layout` | `'below' \| 'inline'` | `below` | `inline` puts a chart beside the number in a fixed box; `below` leaves it under everything. |
+
+### Kpi.Trend
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `value` | `number` | — | The change. Its sign decides the direction. |
+| `format` | `(value: number) => string` | — | Writes the change. Defaults to a signed percentage with one decimal: "+7.8%". |
+| `goodDirection` | `KpiGoodDirection` | `'up'` | Which direction is the good news. `down` for churn, latency, cost; `none` for no colour. |
+| `variant` | `'text' \| 'badge'` | `'text'` | `text` is a line of colour; `badge` puts a pill and an arrow round it. |
+| `caption` | `string` | — | Quiet words after the change: "this month". |
+| `threshold` | `number` | `0` | A change this small or smaller is drawn as no change. |
+
+### Kpi.Chart
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `data` | `number[]` | — | The values, oldest first. |
+| `color` | `KpiColor` | `'primary'` | Which of the theme's roles the line is drawn in. |
+| `filled` | `boolean` | `false` | Fills under the line with a wash of the same colour. |
+| `height` | `number` | — | How tall the chart is, in pixels. |
+| `inline` | `boolean` | `false` | A fixed box for the end of a `layout="inline"` row. Charts beside numbers of different lengths are then all one width, and line up down a column. |
+| `strokeWidth` | `number` | `2` | How thick the line is, in pixels. |
+
+### Kpi.Progress
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `value` | `number` | — | How far along. |
+| `maxValue` | `number` | `100` | What `value` is out of. |
+| `label` | `ReactNode` | — | Words at the start of the row over the bar: "of 5,000". |
+| `showValueLabel` | `boolean` | `false` | Writes the percentage at the end of the row over the bar. |
+| `color` | `KpiColor` | `'primary'` | Which of the theme's roles the bar is drawn in. |
+
+### Kpi.Group
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `orientation` | `'horizontal' \| 'vertical'` | — | Side by side with upright rules, or stacked with level ones. |
+
+Every part also takes the props of the element it renders, and a `className`.
+
+## Notes
+
+### Trend colours
+
+`Kpi.Trend` is drawn in the success colour when the number moved the good way, in the destructive colour when it moved the bad way, and in the muted colour when it did not move. Up is good unless you say otherwise: pass `goodDirection="down"` for a metric where less is better, or `"none"` for one where neither is.
+
+`threshold` is the size of change that still counts as none. With `threshold={0.5}`, a change of 0.3 is drawn flat.
+
+### Writing the change
+
+The change is written as a signed percentage with one decimal. `format` is given the value and returns the text, for a change in another unit. `caption` adds quiet words after it and is read out with it.
+
+### The chart
+
+`data` is a list of numbers, oldest first. The line is scaled between the lowest and highest of them, so it shows the shape of the change and says nothing about its size. That is why it has no axis.
+
+`inline` fixes the chart at 128 by 56 pixels. In a column of cards with titles of different lengths, the charts are then all one width and line up down the right-hand edge.
+
+### Colour
+
+`color` on `Kpi.Chart` and `Kpi.Progress` names one of the theme's roles and defaults to `primary`.
+
+### In a group
+
+`Kpi.Group` pads its children on the sides that face a rule. Put `Kpi` elements straight inside it. In a horizontal group the metrics share the width equally, so keep titles to a word or two, or stack them with `orientation="vertical"`.
+
+### Server Components
+
+KPI holds no state, so it is not a client module. It can be rendered from a Server Component as written, and it adds no JavaScript to the page.
diff --git a/apps/docs/content/docs/web/components/lane-board.mdx b/apps/docs/content/docs/web/components/lane-board.mdx
new file mode 100644
index 00000000..c8ef2bfc
--- /dev/null
+++ b/apps/docs/content/docs/web/components/lane-board.mdx
@@ -0,0 +1,485 @@
+---
+title: Lane Board
+description: Lanes of cards that can be dragged within a lane and from one lane to another.
+status: new
+full: true
+---
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { LaneBoard, type LaneBoardLane } from '@/components/panelui/lane-board';
+
+interface Change {
+ id: string;
+ title: string;
+ /** How many of the five checks have passed. */
+ checks: number;
+ /** How long it has been in its lane. */
+ waiting: string;
+}
+
+/** The changes going into one release, by how far along each is. */
+const START: LaneBoardLane[] = [
+ {
+ id: 'queued',
+ title: 'Queued',
+ cards: [
+ { id: 'REL-231', title: 'Offline drafts', checks: 0, waiting: '2d' },
+ { id: 'REL-238', title: 'Export to CSV', checks: 1, waiting: '5h' },
+ ],
+ },
+ {
+ id: 'building',
+ title: 'Building',
+ cards: [
+ { id: 'REL-224', title: 'Faster search', checks: 2, waiting: '1d' },
+ { id: 'REL-229', title: 'Invite by link', checks: 3, waiting: '3h' },
+ { id: 'REL-233', title: 'Keyboard shortcuts', checks: 2, waiting: '40m' },
+ { id: 'REL-236', title: 'Saved filters', checks: 1, waiting: '25m' },
+ { id: 'REL-240', title: 'Bulk archive', checks: 0, waiting: '10m' },
+ ],
+ },
+ {
+ id: 'review',
+ title: 'In review',
+ cards: [{ id: 'REL-219', title: 'Audit log', checks: 4, waiting: '6h' }],
+ },
+ {
+ id: 'shipped',
+ title: 'Shipped',
+ cards: [
+ { id: 'REL-207', title: 'Two-step sign in', checks: 5, waiting: '' },
+ { id: 'REL-212', title: 'Dark charts', checks: 5, waiting: '' },
+ ],
+ },
+];
+
+export default function LaneBoardRelease() {
+ const [lanes, setLanes] = useState(START);
+
+ return (
+ change.title}
+ renderCard={(change, { lane }) => (
+ <>
+ {change.title}
+ {change.id}
+
+
+ {lane.id === 'shipped' ? (
+ Live
+ ) : (
+ {change.waiting}
+ )}
+
+ >
+ )}
+ />
+ );
+}
+```
+
+
+
+Use it for things that move through a few stages: changes going into a release, tasks, orders. Each stage is a lane with its cards and a count, and a card is dragged to another place in its lane or into another lane.
+
+The board does not keep the cards. You give it the lanes and it tells you what they should become when a card is set down, so the order on screen is always the order in your state.
+
+The board has a fixed height. A lane with more cards than fit scrolls on its own, and on a narrow screen the lanes scroll sideways. For one list that only needs reordering, a sortable list is the simpler thing.
+
+## Installation
+
+
+
+
+
+
+
+The source is copied into `components/panelui/`, where you can edit it.
+
+
+
+
+
+
+
+Import from `panelui-web/lane-board` instead of `@/components/panelui/lane-board`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
+
+
+
+
+
+
+
+
+
+Install the packages it uses:
+
+
+
+
+
+
+
+Copy the source into `components/panelui/lane-board.tsx`:
+
+
+
+
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { LaneBoard, type LaneBoardLane } from "@/components/panelui/lane-board"
+```
+
+```tsx
+const [lanes, setLanes] = useState[]>([
+ { id: 'building', title: 'Building', cards: [{ id: 'REL-224', title: 'Faster search' }] },
+ { id: 'shipped', title: 'Shipped', cards: [] },
+])
+
+ (
+ <>
+ {change.title}
+ {change.id}
+ >
+ )}
+/>
+```
+
+## Composition
+
+```tsx
+ (
+ <>
+
+
+
+
+
+
+ >
+ )}
+/>
+```
+
+- **`LaneBoard.Title`** — A card's name.
+- **`LaneBoard.Code`** — A card's reference, in a fixed-width face so a column of them lines up.
+- **`LaneBoard.Row`** — The last line of a card, with something at each end.
+- **`LaneBoard.Steps`** — How far along a card is: a row of dashes with `value` of `max` lit, in the theme colour named by `color`.
+- **`LaneBoard.Tag`** — A word in a pill, for a card's state.
+- **`LaneBoard.Meta`** — A short figure at the end of the row: a time, a count.
+
+## Examples
+
+### A fuller board
+
+A lane can have a `color`, drawn as a dot before its title, and a `limit`: the count is written against it and turns to the warning colour once the lane holds more. `renderLaneFooter` draws under a lane's cards, here a button that adds one. The cards are built from the same parts, with a tag for priority and the owner's initials.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { PlusIcon } from 'lucide-react';
+import { useState } from 'react';
+import { LaneBoard, type LaneBoardColor, type LaneBoardLane } from '@/components/panelui/lane-board';
+
+type Priority = 'Low' | 'Normal' | 'Urgent';
+
+interface Ticket {
+ id: string;
+ title: string;
+ priority: Priority;
+ /** Whose it is, as initials. */
+ owner: string;
+ /** How many of its four checks have passed. */
+ checks: number;
+}
+
+const TONE: Record = {
+ Low: 'primary',
+ Normal: 'info',
+ Urgent: 'destructive',
+};
+
+/** One sprint of a support team. Each lane has a colour, and two have a limit. */
+const START: LaneBoardLane[] = [
+ {
+ id: 'inbox',
+ title: 'Inbox',
+ color: 'primary',
+ cards: [
+ { id: 'SUP-481', title: 'Refund not showing', priority: 'Urgent', owner: 'NO', checks: 0 },
+ { id: 'SUP-479', title: 'Change the invoice name', priority: 'Low', owner: 'HL', checks: 0 },
+ ],
+ },
+ {
+ id: 'working',
+ title: 'Working on',
+ color: 'info',
+ limit: 2,
+ cards: [
+ { id: 'SUP-472', title: 'Login loop on tablets', priority: 'Urgent', owner: 'TA', checks: 2 },
+ { id: 'SUP-476', title: 'Export stops at row 500', priority: 'Normal', owner: 'NO', checks: 1 },
+ ],
+ },
+ {
+ id: 'waiting',
+ title: 'Waiting on customer',
+ color: 'warning',
+ limit: 3,
+ cards: [{ id: 'SUP-468', title: 'Which plan were you on?', priority: 'Normal', owner: 'HL', checks: 3 }],
+ },
+ {
+ id: 'solved',
+ title: 'Solved',
+ color: 'success',
+ cards: [{ id: 'SUP-461', title: 'Password reset email', priority: 'Low', owner: 'TA', checks: 4 }],
+ },
+];
+
+export default function LaneBoardSprint() {
+ const [lanes, setLanes] = useState(START);
+ const [made, setMade] = useState(0);
+
+ const add = (laneId: string) => {
+ const id = `SUP-${482 + made}`;
+ setMade(made + 1);
+ setLanes((current) =>
+ current.map((lane) =>
+ lane.id === laneId
+ ? {
+ ...lane,
+ cards: [...lane.cards, { id, title: 'New ticket', priority: 'Normal', owner: 'NO', checks: 0 }],
+ }
+ : lane
+ )
+ );
+ };
+
+ return (
+ ticket.title}
+ renderCard={(ticket) => (
+ <>
+
+
+ {ticket.title}
+
+
+ {ticket.owner}
+
+
+ {ticket.id}
+
+ {ticket.priority}
+
+
+ >
+ )}
+ renderLaneFooter={(lane) =>
+ lane.id === 'inbox' ? (
+ add(lane.id)}
+ className="flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-2xl text-sm text-muted-foreground outline-none transition-colors hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
+ >
+
+ Add a ticket
+
+ ) : null
+ }
+ />
+ );
+}
+```
+
+
+
+### Refusing a move
+
+`onLanesChange` is an offer. Here a task with steps left is not let into Done: the lanes it was offered are not applied, so the card goes back to where it was.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { LaneBoard, type LaneBoardLane } from '@/components/panelui/lane-board';
+
+interface Task {
+ id: string;
+ title: string;
+ /** How many of its three steps are done. */
+ done: number;
+}
+
+const START: LaneBoardLane[] = [
+ {
+ id: 'todo',
+ title: 'To do',
+ cards: [
+ { id: 'T-14', title: 'Write the migration', done: 1 },
+ { id: 'T-17', title: 'Update the runbook', done: 3 },
+ ],
+ },
+ { id: 'doing', title: 'Doing', cards: [{ id: 'T-11', title: 'Rotate the keys', done: 2 }] },
+ { id: 'done', title: 'Done', cards: [] },
+];
+
+export default function LaneBoardRules() {
+ const [lanes, setLanes] = useState(START);
+ const [refused, setRefused] = useState(null);
+
+ return (
+
+
{
+ const unfinished = next
+ .find((lane) => lane.id === 'done')
+ ?.cards.find((task) => task.done < 3);
+ if (unfinished) {
+ setRefused(unfinished.title);
+ return;
+ }
+ setRefused(null);
+ setLanes(next);
+ }}
+ cardName={(task) => task.title}
+ renderCard={(task) => (
+ <>
+ {task.title}
+
+
+ {task.done}/3
+
+ >
+ )}
+ />
+
+ {refused ? `${refused} still has steps left, so it stays where it was.` : 'Only a task with all three steps done can go to Done.'}
+
+
+ );
+}
+```
+
+
+
+## API Reference
+
+### LaneBoard
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `lanes` | `LaneBoardLane[]` | — | The lanes and their cards, when the caller holds them. |
+| `defaultLanes` | `LaneBoardLane[]` | — | The lanes to begin with, when the caller does not hold them. |
+| `onLanesChange` | `(lanes: LaneBoardLane[]) => void` | — | Called with the lanes as they should now be, when a card is set down somewhere new. |
+| `onMove` | `(move: LaneBoardMove) => void` | — | Called with the one card that moved, and where from and to. |
+| `renderCard` | `(card: T, state: { lane: LaneBoardLane; carried: boolean }) => ReactNode` | — | Draws the inside of a card. |
+| `renderLaneFooter` | `(lane: LaneBoardLane) => ReactNode` | — | Draws something under a lane's cards, such as a button that adds one. |
+| `cardName` | `(card: T) => string` | `(card) => card.id` | What a screen reader calls a card, where its id would not mean much. |
+| `label` | `string` | `'Board'` | What a screen reader calls the board. |
+| `height` | `number \| string` | `'28rem'` | How tall the board is: a number of pixels, or any CSS length. Lanes fill it, and one with more cards than fit scrolls. `'auto'` lets the board grow with its longest lane. |
+
+### LaneBoard.Steps
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `value` | `number` | — | How many steps are done. |
+| `max` | `number` | `5` | How many steps there are. |
+| `color` | `LaneBoardColor` | `'primary'` | Which of the theme's roles the done steps are drawn in. |
+| `label` | `string` | `'Progress'` | What a screen reader calls it. |
+
+### LaneBoard.Tag
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `color` | `LaneBoardColor` | `primary` | Which of the theme's roles the tag is drawn in. |
+
+Every part also takes the props of the element it renders, and a `className`.
+
+## Notes
+
+### The data
+
+`lanes` is a list of `{ id, title, cards }`, and a card is any object with an `id` of its own across the whole board. Everything else on a card is yours, and `renderCard` draws it. The parts above are there for the common pieces and none of them is required.
+
+Pass `defaultLanes` in place of `lanes` and the board holds the order itself.
+
+### Lanes
+
+Besides `id`, `title` and `cards`, a lane takes `color`, one of the theme's roles, and `limit`. The limit is advice: the count shows it and warns when the lane is over, and the board still lets a card in. To hold a lane to its limit, refuse the move in `onLanesChange`, as the second example refuses one.
+
+`renderLaneFooter` is given the lane and draws under its cards. A button there is pressed as usual and does not start a drag.
+
+### Hearing about a move
+
+`onLanesChange` is called with all the lanes as they should now be. `onMove` is called with the one card and where it went from and to, which is the shape a request to a server usually wants. Neither is called when a card is set down where it started.
+
+### Dragging
+
+A press on a card becomes a drag once the pointer has moved a few pixels. On a touch screen a card is picked up by resting a finger on it for a moment, so a swipe still scrolls. A button, a link or a field inside a card works as usual and does not start a drag; put `data-no-drag` on anything else that should not.
+
+The lane a carried card is over is drawn with a firmer border, and the place it would land is outlined. Near either end of the board, a carried card scrolls it. Escape puts the card back.
+
+### Keyboard
+
+Every card is a Tab stop. Space or Enter picks one up; up and down move it within its lane, left and right into the lane beside; Space or Enter sets it down and Escape puts it back. Moving focus away also puts it back.
+
+### Screen readers
+
+Picking up, each move and setting down are announced with the card's name, its lane and its place in it. `cardName` says what a card is called; without it, its `id` is used. A lane is named by its `title` when that is plain text.
+
+### Width
+
+Lanes share the board's width and are never narrower than 12.5rem. When they do not fit, the board scrolls sideways and stops on a lane.
+
+### Height
+
+The board is 28rem tall unless `height` says otherwise: a number of pixels or any CSS length. Every lane fills it. A lane with more cards than fit scrolls, with a thin bar that shows while the pointer is on the lane, and its cards fade out towards the edge they carry on past. Adding a card to a full lane therefore moves nothing else on the page.
+
+A card carried near the top or bottom of a lane scrolls it, so a card can be set down anywhere in a long lane. What `renderLaneFooter` draws stays under the lane and does not scroll away.
+
+`height="auto"` lets the board grow with its longest lane. It then keeps the greatest height it has had and does not shrink when cards leave that lane, so nothing moves under the pointer during a drag.
+
+### Motion
+
+Cards slide to their new places as one is carried over them, and a card that is let go flies the last of the way into its place. A card moved to another lane with the keyboard appears there without sliding. With reduced motion on, they move at once.
+
+### Server Components
+
+`LaneBoard` is a client component. In a file that starts with `"use client"`, write its parts with a dot, as on this page. A Server Component cannot read `LaneBoard.Title` off the import, so each part is also exported by name: `LaneBoardTitle` is `LaneBoard.Title`, and so on for the others.
+
+```tsx
+import { LaneBoard, LaneBoardTitle, LaneBoardCode, LaneBoardRow, LaneBoardSteps, LaneBoardTag, LaneBoardMeta } from "@/components/panelui/lane-board"
+```
diff --git a/apps/docs/content/docs/web/components/meta.json b/apps/docs/content/docs/web/components/meta.json
new file mode 100644
index 00000000..619bcb32
--- /dev/null
+++ b/apps/docs/content/docs/web/components/meta.json
@@ -0,0 +1,17 @@
+{
+ "title": "Components",
+ "pages": [
+ "!index",
+ "bar-picker",
+ "command-dock",
+ "day-grid",
+ "feedback",
+ "flow",
+ "kpi",
+ "lane-board",
+ "planet",
+ "range-meter",
+ "rolling-number",
+ "tilt-card"
+ ]
+}
diff --git a/apps/docs/content/docs/web/components/planet.mdx b/apps/docs/content/docs/web/components/planet.mdx
new file mode 100644
index 00000000..7f8a1c89
--- /dev/null
+++ b/apps/docs/content/docs/web/components/planet.mdx
@@ -0,0 +1,466 @@
+---
+title: Planet
+description: The world as dots on a globe you can turn, with markers pinned to it.
+status: new
+---
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useRef, useState } from 'react';
+import { Planet, type PlanetHandle, type PlanetMarkerColor } from '@/components/panelui/planet';
+
+type Health = 'healthy' | 'slow' | 'down';
+
+/** The data centres a service runs in, and how each is answering. */
+const REGIONS: { city: string; lat: number; lng: number; health: Health; ms: number | null }[] = [
+ { city: 'Dublin', lat: 53.35, lng: -6.26, health: 'healthy', ms: 38 },
+ { city: 'Warsaw', lat: 52.23, lng: 21.01, health: 'healthy', ms: 44 },
+ { city: 'Johannesburg', lat: -26.2, lng: 28.05, health: 'slow', ms: 412 },
+ { city: 'Mumbai', lat: 19.08, lng: 72.88, health: 'healthy', ms: 61 },
+ { city: 'Singapore', lat: 1.35, lng: 103.82, health: 'healthy', ms: 52 },
+ { city: 'Sydney', lat: -33.87, lng: 151.21, health: 'down', ms: null },
+ { city: 'Seoul', lat: 37.57, lng: 126.98, health: 'healthy', ms: 47 },
+ { city: 'São Paulo', lat: -23.55, lng: -46.63, health: 'healthy', ms: 73 },
+ { city: 'Montreal', lat: 45.5, lng: -73.57, health: 'healthy', ms: 41 },
+ { city: 'Portland', lat: 45.52, lng: -122.68, health: 'slow', ms: 286 },
+];
+
+const COLOUR: Record = {
+ healthy: 'success',
+ slow: 'warning',
+ down: 'destructive',
+};
+
+/** The same three colours for the dots in the list. */
+const DOT: Record = {
+ healthy: 'bg-(--success)',
+ slow: 'bg-(--warning)',
+ down: 'bg-destructive',
+};
+
+const SAYS: Record = {
+ healthy: 'Answering normally',
+ slow: 'Answering slowly',
+ down: 'Not answering',
+};
+
+export default function PlanetRegions() {
+ const planet = useRef(null);
+ const [picked, setPicked] = useState('Singapore');
+ const region = REGIONS.find((entry) => entry.city === picked) ?? REGIONS[0];
+ const healthy = REGIONS.filter((entry) => entry.health === 'healthy').length;
+
+ const pick = (city: string) => {
+ const next = REGIONS.find((entry) => entry.city === city);
+ if (!next) return;
+ setPicked(city);
+ // Turned so the region faces you, a little short of its latitude so a
+ // place far north or south is seen from the side.
+ planet.current?.rotateTo([next.lng, next.lat * 0.5]);
+ };
+
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Regions
+
+ {healthy} of {REGIONS.length} healthy
+
+
+
+ {/* The globe beside the list on a wide screen, over it on a narrow one. */}
+
+
+
+ {REGIONS.map((entry) => (
+ pick(entry.city)}
+ />
+ ))}
+
+
+
+
+
+
+
+
{region.city}
+
{SAYS[region.health]}
+
+
+ {region.ms === null ? '\u2014' : region.ms}
+ {region.ms === null ? null : (
+ ms
+ )}
+
+
+
+
+ {REGIONS.map((entry) => (
+
+ pick(entry.city)}
+ className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-start text-sm text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-muted aria-pressed:text-foreground"
+ >
+
+ {entry.city}
+
+
+ ))}
+
+
+
+
+ );
+}
+```
+
+
+
+Use it to show where things are in the world when the answer is a handful of places: the regions a service runs in, offices, where a team is this week. The land is drawn as dots and the markers are your own elements, so a marker can hold initials, an icon or a link.
+
+Drag the globe to turn it. `Planet.Controls` adds two buttons for the same thing, and the arrow keys work when the globe has focus.
+
+It shows that a place is somewhere, not exactly where. A marker covers a few hundred kilometres at this scale, so for anything a street address matters to, use a map.
+
+## Installation
+
+
+
+
+
+
+
+The source is copied into `components/panelui/`, where you can edit it.
+
+
+
+
+
+
+
+Import from `panelui-web/planet` instead of `@/components/panelui/planet`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
+
+
+
+
+
+
+
+
+
+Install the packages it uses:
+
+
+
+
+
+
+
+Copy the source into `components/panelui/planet/`, one file for each below:
+
+
+
+
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { Planet } from "@/components/panelui/planet"
+```
+
+```tsx
+
+
+
+
+
+
+
+```
+
+## Composition
+
+```tsx
+
+
+ …
+
+
+
+```
+
+- **`Planet.Globe`** — The sphere and its dots, with a soft light round its edge in the theme colour named by `color`. Markers go inside it.
+- **`Planet.Marker`** — An element pinned to a latitude and longitude. Without children it is a dot in the theme colour named by `color`; with children they are drawn at the point, in a circle ringed in that colour. `label` and `count` go in a tag above it, `pulse` spreads a fading ring from it, and `onClick` makes it a button.
+- **`Planet.Controls`** — Two buttons joined in one group that turn the globe by `step` degrees. Children are a caption between them.
+
+## Examples
+
+### Pins
+
+A marker without children is a dot. Give it a `label` to name the place.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { Planet } from '@/components/panelui/planet';
+
+const OFFICES = [
+ { city: 'London', lat: 51.51, lng: -0.13 },
+ { city: 'Lagos', lat: 6.52, lng: 3.38 },
+ { city: 'Dubai', lat: 25.2, lng: 55.27 },
+ { city: 'Bengaluru', lat: 12.97, lng: 77.59 },
+ { city: 'Singapore', lat: 1.35, lng: 103.82 },
+];
+
+export default function PlanetPins() {
+ return (
+
+
+ {OFFICES.map((office) => (
+
+ ))}
+
+
+ );
+}
+```
+
+
+
+### Something in the pin
+
+Children of a marker are drawn in a circle at the point. Initials fit, and so does an image or an icon.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { Planet } from '@/components/panelui/planet';
+
+/** Where a remote team is working from this week. */
+const TEAM = [
+ { initials: 'IF', city: 'Lisbon', lat: 38.72, lng: -9.14 },
+ { initials: 'TN', city: 'Nairobi', lat: -1.29, lng: 36.82 },
+ { initials: 'KS', city: 'Tbilisi', lat: 41.72, lng: 44.79 },
+ { initials: 'RA', city: 'Colombo', lat: 6.93, lng: 79.85 },
+ { initials: 'MV', city: 'Helsinki', lat: 60.17, lng: 24.94 },
+];
+
+export default function PlanetPeople() {
+ return (
+
+
+ {TEAM.map((person) => (
+
+
+ {person.initials}
+
+
+ ))}
+
+
+ );
+}
+```
+
+
+
+### Turning on its own
+
+`autoRotate` takes degrees of longitude a second, or `true` for 6. It pauses while the pointer is over the globe, while the globe is off screen, and for anyone who has asked their system for reduced motion.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { Planet } from '@/components/panelui/planet';
+
+export default function PlanetTurning() {
+ return (
+
+
+
+ );
+}
+```
+
+
+
+### Turning it from outside
+
+A ref on `Planet` is a handle with `rotateTo`, `rotateBy` and `getRotation`. `rotateTo` takes `[longitude, latitude]` and turns the short way round.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useRef, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Planet, type PlanetHandle } from '@/components/panelui/planet';
+
+const CITIES = [
+ { name: 'Tokyo', lat: 35.68, lng: 139.69 },
+ { name: 'Nairobi', lat: -1.29, lng: 36.82 },
+ { name: 'Reykjavík', lat: 64.15, lng: -21.94 },
+ { name: 'São Paulo', lat: -23.55, lng: -46.63 },
+];
+
+export default function PlanetFlyTo() {
+ const planet = useRef(null);
+ const [city, setCity] = useState(CITIES[0].name);
+
+ return (
+
+
+
+ {CITIES.map((place) => (
+
+ ))}
+
+
+
+
+ {CITIES.map((place) => (
+ {
+ setCity(place.name);
+ // The latitude is eased towards the equator so a city near a
+ // pole is seen from the side, not from directly above.
+ planet.current?.rotateTo([place.lng, place.lat * 0.6]);
+ }}
+ >
+ {place.name}
+
+ ))}
+
+
+ );
+}
+```
+
+
+
+## API Reference
+
+### Planet
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `defaultRotation` | `PlanetRotation` | — | The coordinate facing the viewer to begin with, as `[longitude, latitude]` in degrees. Read once: turning the globe afterwards does not change it. |
+| `autoRotate` | `boolean \| number` | `false` | Turn on its own, at this many degrees of longitude a second. `true` is 6. It stops while the pointer is over the globe, while it is being dragged, while it is off screen, and for anyone who has asked for reduced motion. |
+| `interactive` | `boolean` | `true` | Let the globe be dragged and turned with the arrow keys. |
+| `onRotationChange` | `(rotation: PlanetRotation) => void` | — | Called with the coordinate facing the viewer each time the globe comes to rest. |
+
+### Planet.Globe
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `color` | `PlanetMarkerColor` | `'primary'` | Which of the theme's roles the light round the globe is drawn in. |
+| `label` | `string` | `'Globe'` | What a screen reader calls the globe. |
+
+### Planet.Marker
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `lat` | `number` | — | Degrees north of the equator. South is negative. |
+| `lng` | `number` | — | Degrees east of Greenwich. West is negative. |
+| `label` | `ReactNode` | — | A caption in a tag above the pin. |
+| `count` | `ReactNode` | — | A figure after the label, in a quieter colour. |
+| `color` | `PlanetMarkerColor` | `'primary'` | Which of the theme's roles the dot, or the ring of a pin with children, is drawn in. |
+| `pulse` | `boolean` | `false` | Spreads a fading ring from the marker, for one that wants looking at. |
+| `onClick` | `(event: MouseEvent) => void` | — | Called when the marker is pressed. Passing this makes the marker a button: it takes focus, and Enter or Space press it. |
+| `selected` | `boolean` | `false` | Draws the marker as the one that has been picked. |
+| `name` | `string` | — | What a screen reader calls a marker that can be pressed. Defaults to its label. |
+| `labelClassName` | `string` | — | Extra classes for the label. |
+
+### Planet.Controls
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `step` | `number` | — | Degrees of longitude one press turns the globe. |
+
+Every part also takes the props of the element it renders, and a `className`.
+
+## Notes
+
+### Size
+
+The globe is a square as wide as its container, up to 28rem. Set a width on `Planet` or on `Planet.Globe` with `className` to change it. The dots keep the same spacing in pixels at every size, so a small globe has fewer of them and stays crisp.
+
+### Colour
+
+The dots and the shading are drawn in the text colour of the element. On a dark card the land is light, on a light card it is dark. A text colour class on `Planet.Globe`, such as `text-muted-foreground`, changes it.
+
+`color` on `Planet.Globe` sets the light round the globe's edge, which is `primary` unless it names another of the theme's roles.
+
+A marker's dot is the `primary` colour unless `color` names another of the theme's roles. Use the status roles when the colour means something, as the first example does for regions that are slow or down.
+
+### Rotation
+
+`defaultRotation` is `[longitude, latitude]` of the point facing you, in degrees. It sets the opening view only. To move the globe afterwards, use the handle or let the reader drag it. `onRotationChange` is called each time the globe comes to rest.
+
+The tilt stops at 60 degrees either way. Past that the far pole comes into view and the continents are seen from above, where they are hard to recognise.
+
+### Markers near the edge
+
+A marker fades as its point approaches the edge of the globe and is gone when it passes behind. It stays in the page for a screen reader the whole time. Labels are not kept apart: two markers a few degrees from each other overlap, so merge nearby places into one marker with a `count`.
+
+### Pressing a marker
+
+Give a marker `onClick` and it becomes a button: pressing its pin or its label calls it, it can be reached with Tab, and it grows a little under the pointer. `selected` draws it as the one that has been picked, larger and with a ring of its colour. A marker on the far side of the globe cannot be pressed or tabbed to.
+
+A pressable marker is named for a screen reader by its `label`. Pass `name` when the label is not plain text or is only shown some of the time.
+
+### Touch
+
+On a touch screen a horizontal drag turns the globe and a vertical one scrolls the page. Tilting needs a mouse, a pen or the arrow keys.
+
+### Keyboard and screen readers
+
+The globe is announced as a slider whose value is the longitude facing you. Left and right turn it 15 degrees, up and down tilt it. Pass `interactive={false}` for a globe that cannot be turned, which is announced as an image.
+
+### The land data
+
+The outlines are Natural Earth's 1:110m land polygons, which are in the public domain, reduced to one bit per square degree. Small islands are missing at that resolution, so a marker on one sits on open water.
+
+### Server Components
+
+`Planet` is a client component. In a file that starts with `"use client"`, write its parts with a dot, as on this page. A Server Component cannot read `Planet.Globe` off the import, so each part is also exported by name: `PlanetGlobe` is `Planet.Globe`, and so on for the others.
+
+```tsx
+import { Planet, PlanetGlobe, PlanetMarker, PlanetControls } from "@/components/panelui/planet"
+```
diff --git a/apps/docs/content/docs/web/components/range-meter.mdx b/apps/docs/content/docs/web/components/range-meter.mdx
new file mode 100644
index 00000000..5435d58f
--- /dev/null
+++ b/apps/docs/content/docs/web/components/range-meter.mdx
@@ -0,0 +1,281 @@
+---
+title: Range Meter
+description: A reading on a ruler of ticks that rises where the value is, with the number above it.
+status: new
+---
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { RangeMeter, type RangeMeterColor } from '@/components/panelui/range-meter';
+
+const FASTEST = 80;
+const SLOWEST = 600;
+/** What the team has agreed a page may take. */
+const BUDGET = 300;
+
+/** Fine under the budget, a warning up to half as much again, a problem past that. */
+const colorOf = (time: number): RangeMeterColor =>
+ time <= BUDGET ? 'success' : time <= BUDGET * 1.5 ? 'warning' : 'destructive';
+
+const verdictOf = (time: number) =>
+ time <= BUDGET ? `${BUDGET - time} ms inside budget` : `${time - BUDGET} ms over budget`;
+
+export default function RangeMeterResponseTime() {
+ const [time, setTime] = useState(240);
+
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Checkout page
+
Budget {BUDGET} ms
+
+
+
+
+
+
Response time
+
+ {time}
+ ms
+
+
+
+ {verdictOf(time)}
+
+
+
+
+
+
+ {FASTEST} ms
+ {SLOWEST} ms
+
+
+
+
+ );
+}
+```
+
+
+
+Use it for one reading on a known scale where the position matters as much as the number: a response time between the fastest and the slowest, a level between empty and full. The scale is a ruler of ticks that rises where the reading is, with the number in a tag above the rise.
+
+With `onValueChange` it is a slider that can be dragged. Without it, the same drawing is a meter that only shows a value.
+
+It needs about 12rem of width for the rise to read as one. In a tighter space, or for a reading that needs no number, use a progress bar.
+
+## Installation
+
+
+
+
+
+
+
+The source is copied into `components/panelui/`, where you can edit it.
+
+
+
+
+
+
+
+Import from `panelui-web/range-meter` instead of `@/components/panelui/range-meter`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
+
+
+
+
+
+
+
+
+
+Install the packages it uses:
+
+
+
+
+
+
+
+Copy the source into `components/panelui/range-meter.tsx`:
+
+
+
+
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { RangeMeter } from "@/components/panelui/range-meter"
+```
+
+```tsx
+
+```
+
+## Examples
+
+### A reading that cannot be changed
+
+Leave out `onValueChange` and it is announced as a meter, takes no focus and ignores the pointer. `formatValue` writes the number in the tag.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { RangeMeter } from '@/components/panelui/range-meter';
+
+/** Three readings nobody can change: without `onValueChange` it is a meter. */
+const READINGS = [
+ { name: 'Build cache', used: 71, color: 'primary' },
+ { name: 'Artifact storage', used: 88, color: 'warning' },
+ { name: 'Log retention', used: 34, color: 'info' },
+] as const;
+
+export default function RangeMeterReadings() {
+ return (
+
+ {READINGS.map((reading) => (
+
+ {reading.name}
+ `${value}%`}
+ label={reading.name}
+ valueText={`${reading.used} percent used`}
+ />
+
+ ))}
+
+ );
+}
+```
+
+
+
+### Fewer ticks and an icon
+
+`ticks` sets how many the ruler is drawn with. Here there are 21, one for each step of 5. Children are drawn in the tag before the number, which is the place for an icon.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { SunIcon } from 'lucide-react';
+import { useState } from 'react';
+import { RangeMeter } from '@/components/panelui/range-meter';
+
+export default function RangeMeterBrightness() {
+ const [brightness, setBrightness] = useState(65);
+
+ return (
+
+
`${value}%`}
+ label="Brightness"
+ valueText={`${brightness} percent`}
+ >
+
+
+
+ Dim
+ Bright
+
+
+ );
+}
+```
+
+
+
+## API Reference
+
+### RangeMeter
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `min` | `number` | `0` | The low end of the ruler. |
+| `max` | `number` | `100` | The high end of the ruler. |
+| `value` | `number` | — | The reading: where the ruler rises and the number written above it. |
+| `onValueChange` | `(value: number) => void` | — | Called with the new value as the ruler is dragged or moved with the keyboard. Passing this makes it a slider; without it, it is a meter. |
+| `step` | `number` | `1` | How much one arrow key press moves the value, and what a drag snaps to. |
+| `marker` | `number` | — | A value to mark under the ruler, such as a target. |
+| `ticks` | `number` | `41` | How many ticks the ruler is drawn with. |
+| `color` | `RangeMeterColor` | `'primary'` | Which of the theme's roles the lit ticks and the tag are drawn in. |
+| `formatValue` | `(value: number) => ReactNode` | — | Writes the number in the tag. Defaults to the value as it is. |
+| `label` | `string` | `'Reading'` | What a screen reader calls it. |
+| `valueText` | `string` | — | What a screen reader says for the value: "240 milliseconds". |
+
+Every part also takes the props of the element it renders, and a `className`.
+
+## Notes
+
+### Scale
+
+`min` and `max` are the ends of the ruler and default to 0 and 100. A `value` outside them is drawn at the nearest end and still announced as given.
+
+`step` is what one arrow key press moves the value by, and what a drag snaps to.
+
+### Ticks
+
+The ruler has 41 ticks unless `ticks` says otherwise. They are decoration and not graduations: the reading is placed by its value, between two ticks if that is where it falls. Set `ticks` so one tick is a round amount if you want them to line up with the steps.
+
+### The tag stays inside
+
+The tag sits over the reading and is hung from it in proportion to the reading's place on the ruler: by its left edge at the lowest value, its middle half way and its right edge at the highest. Nothing hangs outside the control, so it can be placed against the edge of a card.
+
+The control is taller than the ruler by the height of the tag, which is drawn inside its box.
+
+### Marker
+
+`marker` draws a small diamond under the ruler at a value, such as a target or a budget. It is hidden from screen readers, so say what it marks in text beside the meter.
+
+### Colour
+
+`color` names one of the theme's roles and colours the lit ticks and the tag. To show that a reading has crossed a threshold, work out the role from the value and pass it, as the first example does.
+
+### Keyboard
+
+Left and down lower the value by one step, right and up raise it. Page Up and Page Down move a tenth of the range, and Home and End go to the ends.
+
+### Screen readers
+
+Pass `label` to name the reading and `valueText` to say the value with its unit, such as "240 milliseconds".
diff --git a/apps/docs/content/docs/web/components/rolling-number.mdx b/apps/docs/content/docs/web/components/rolling-number.mdx
new file mode 100644
index 00000000..b897db24
--- /dev/null
+++ b/apps/docs/content/docs/web/components/rolling-number.mdx
@@ -0,0 +1,277 @@
+---
+title: Rolling Number
+description: A number whose digits roll to their new values.
+status: new
+---
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useEffect, useState } from 'react';
+import { RollingNumber } from '@/components/panelui/rolling-number';
+
+/** Jobs waiting in a queue, read every couple of seconds. */
+const WAITING = [312, 298, 305, 341, 327, 289];
+
+export default function RollingNumberQueue() {
+ const [reading, setReading] = useState(0);
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setReading((current) => (current + 1) % WAITING.length);
+ }, 2200);
+ return () => clearInterval(timer);
+ }, []);
+
+ return (
+
+ Jobs waiting
+
+
+ );
+}
+```
+
+
+
+Use it for a figure that changes while someone is looking at it: a live count, a balance, a score. Each digit rolls to its new value on its own, so going from 312 to 318 moves one column and leaves the other two where they were.
+
+The number is written by `Intl.NumberFormat`. Currency, percent and compact notation are options you pass, not strings you build.
+
+For a number that is shown once and never changes, plain text does the same job.
+
+## Installation
+
+
+
+
+
+
+
+The source is copied into `components/panelui/`, where you can edit it.
+
+
+
+
+
+
+
+Import from `panelui-web/rolling-number` instead of `@/components/panelui/rolling-number`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
+
+
+
+
+
+
+
+
+
+Install the packages it uses:
+
+
+
+
+
+
+
+Copy the source into `components/panelui/rolling-number.tsx`:
+
+
+
+
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { RollingNumber } from "@/components/panelui/rolling-number"
+```
+
+```tsx
+
+```
+
+## Examples
+
+### Currency
+
+`format` takes what `Intl.NumberFormat` takes. The currency sign and the separators stay where they are while the digits roll.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { RollingNumber } from '@/components/panelui/rolling-number';
+
+export default function RollingNumberCurrency() {
+ const [total, setTotal] = useState(1864.5);
+
+ return (
+
+
+ Trip budget left
+
+
+
+ setTotal((value) => value - 72.4)}>
+ Book a train
+
+ setTotal((value) => value + 350)}>
+ Add a refund
+
+
+
+ );
+}
+```
+
+
+
+### Percent and compact
+
+Three figures in three notations, changed together. Compact notation keeps a large number short: 46,200 is written `$46.2K`.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { RollingNumber } from '@/components/panelui/rolling-number';
+
+/** Three weeks after a launch. */
+const WEEKS = [
+ { downloads: 18420, paid: 0.031, sales: 46200 },
+ { downloads: 21960, paid: 0.038, sales: 61850 },
+ { downloads: 19310, paid: 0.027, sales: 39400 },
+];
+
+export default function RollingNumberFormats() {
+ const [week, setWeek] = useState(0);
+ const { downloads, paid, sales } = WEEKS[week];
+
+ return (
+
+
+
+
Downloads
+
+
+
+
+
+
Trial to paid
+
+
+
+
+
+
Sales
+
+
+
+
+
+
setWeek((current) => (current + 1) % WEEKS.length)}
+ >
+ Next week
+
+
+ );
+}
+```
+
+
+
+### A slower roll
+
+`duration` is how long one digit takes to arrive, in milliseconds. It is 600 unless you set it.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { RollingNumber } from '@/components/panelui/rolling-number';
+
+export default function RollingNumberSlow() {
+ const [score, setScore] = useState(2048);
+
+ return (
+
+
+ setScore((value) => value * 2)}>
+ Double it
+
+
+ );
+}
+```
+
+
+
+## API Reference
+
+### RollingNumber
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `value` | `number` | — | The number to show. Each digit rolls to its new value on its own. |
+| `format` | `Intl.NumberFormatOptions` | — | How the number is written: currency, percent, compact notation, how many decimals. Passed to `Intl.NumberFormat`. |
+| `locales` | `Intl.LocalesArgument` | `'en-US'` | The locale the number is written in. Defaults to `en-US` rather than the browser's, so a page rendered on a server writes the same thing the browser then does. |
+| `duration` | `number` | `600` | How long a digit takes to roll, in milliseconds. |
+
+Every part also takes the props of the element it renders, and a `className`.
+
+## Notes
+
+### Width
+
+The digits are set in tabular figures, so a 1 is as wide as an 8 and the number keeps its width while it rolls. It does get wider when it gains a digit, as 99 does on its way to 100. Give it room, or a fixed width, where something sits beside it.
+
+### Server rendering
+
+`locales` defaults to `en-US`, not to the browser's locale. A page rendered on a server would otherwise write the number one way and the browser another, and React reports that as a hydration mismatch. Pass the locale you want written.
+
+### Reduced motion
+
+With the system's reduce-motion setting on, a digit changes without rolling.
+
+### Screen readers
+
+A screen reader is given the formatted number once, as text. The rolling columns are hidden from it, so it does not read ten digits per column.
diff --git a/apps/docs/content/docs/web/components/tilt-card.mdx b/apps/docs/content/docs/web/components/tilt-card.mdx
new file mode 100644
index 00000000..b165c23f
--- /dev/null
+++ b/apps/docs/content/docs/web/components/tilt-card.mdx
@@ -0,0 +1,240 @@
+---
+title: Tilt Card
+description: A card that leans towards the pointer, with layers inside it that move by their depth.
+status: new
+---
+
+
+
+```tsx lineNumbers
+'use client';
+
+import type { ReactNode } from 'react';
+import { TiltCard, type TiltCardColor } from '@/components/panelui/tilt-card';
+
+/** Three sheets, one over the other, each lifted further off the card. */
+function Sheets() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Two rings and a body on the outer one. */
+function Orbit() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** A field of marks, with one of them picked out and nearer. */
+function Field() {
+ return (
+
+ {Array.from({ length: 16 }, (_, index) =>
+ index === 6 ? (
+
+ ) : (
+
+ )
+ )}
+
+ );
+}
+
+const CARDS: { title: string; body: string; color: TiltCardColor; figure: ReactNode }[] = [
+ { title: 'Versions', body: 'Every save is kept, and any of them can be brought back.', color: 'success', figure: },
+ { title: 'Sync', body: 'Changes reach your other devices within a few seconds.', color: 'info', figure: },
+ { title: 'Search', body: 'One box finds a word in any note, however old.', color: 'warning', figure: },
+];
+
+export default function TiltCardFigures() {
+ return (
+
+ {CARDS.map((card) => (
+
+ {card.figure}
+
+ {card.title}
+ {card.body}
+
+
+ ))}
+
+ );
+}
+```
+
+
+
+Use it where a card is there to be looked at more than read: a feature on a landing page, a plan, something collectable. The card leans a few degrees towards the pointer and a soft light sits under it. Layers inside the card are lifted off it and move against it as it leans, which gives a flat drawing a front and a back.
+
+The lean is decoration. It does nothing on a touch screen and holds still under reduced motion, so anything the card has to say goes in its contents.
+
+For cards in a dense grid that someone works through, such as a list of records, use a plain card. A screen where everything leans is tiring to use.
+
+## Installation
+
+
+
+
+
+
+
+The source is copied into `components/panelui/`, where you can edit it.
+
+
+
+
+
+
+
+Import from `panelui-web/tilt-card` instead of `@/components/panelui/tilt-card`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm).
+
+
+
+
+
+
+
+
+
+Install the packages it uses:
+
+
+
+
+
+
+
+Copy the source into `components/panelui/tilt-card.tsx`:
+
+
+
+
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { TiltCard } from "@/components/panelui/tilt-card"
+```
+
+```tsx
+
+ {figure}
+
+ Sync
+
+
+```
+
+## Composition
+
+```tsx
+
+ …
+
+```
+
+- **`TiltCard.Layer`** — Something lifted off the card by `depth`, in pixels. The further out, the further it moves as the card leans.
+
+## Examples
+
+### Without layers or light
+
+A card with nothing but its contents still leans. `tilt` is how far, in degrees, and `glare={false}` leaves out the light under the pointer.
+
+
+
+```tsx lineNumbers
+'use client';
+
+import { TiltCard } from '@/components/panelui/tilt-card';
+
+export default function TiltCardPlain() {
+ return (
+
+ Team plan
+
+ €12 a seat, monthly
+
+
+ Shared notebooks, version history for a year, and single sign-on.
+
+
+ );
+}
+```
+
+
+
+## API Reference
+
+### TiltCard
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `tilt` | `number` | `8` | The furthest the card leans, in degrees. |
+| `color` | `TiltCardColor` | `'primary'` | Which of the theme's roles the light under the pointer is drawn in. |
+| `glare` | `boolean` | `true` | Set to `false` for a card with no light under the pointer. |
+| `cardClassName` | `string` | — | Extra classes for the card itself. `className` goes on the box round it. |
+
+### TiltCard.Layer
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `depth` | `number` | — | How far off the card it is lifted, in pixels. Further out moves further as the card leans. |
+
+Every part also takes the props of the element it renders, and a `className`.
+
+## Notes
+
+### Two boxes
+
+The component is a box that sets the viewpoint, with the card inside it. `className` goes on the outer box, which is where a width belongs. `cardClassName` goes on the card, which is where padding and layout belong.
+
+### Depth
+
+A layer's `depth` is how many pixels it stands off the card. At the edge of the card a layer moves by about that many pixels, so depths between 6 and 36 read well, and anything over 50 starts to come apart from the card. Text reads best at 12 or under.
+
+Layers can be positioned however you like, absolutely included. A layer's own transform is used for the movement, so put any transform of your own on an element inside it.
+
+### Colour
+
+`color` names one of the theme's roles and tints the light under the pointer. It defaults to `primary`.
+
+### What it is clipped by
+
+The card clips its contents to its rounded corners, so a layer that moves past the edge is cut off there. Leave a layer room to move.
+
+### Touch and reduced motion
+
+The card does not lean under a finger, and does not lean at all for anyone who has asked their system for reduced motion. It is then a card.
+
+### Server Components
+
+`TiltCard` is a client component. In a file that starts with `"use client"`, write its parts with a dot, as on this page. A Server Component cannot read `TiltCard.Layer` off the import, so each part is also exported by name: `TiltCardLayer` is `TiltCard.Layer`.
+
+```tsx
+import { TiltCard, TiltCardLayer } from "@/components/panelui/tilt-card"
+```
diff --git a/apps/docs/content/docs/web/index.mdx b/apps/docs/content/docs/web/index.mdx
new file mode 100644
index 00000000..aa3343f6
--- /dev/null
+++ b/apps/docs/content/docs/web/index.mdx
@@ -0,0 +1,47 @@
+---
+title: Introduction
+description: PanelUI for the web is a collection of animated, data-rich React components built on shadcn. Copy one into your project or install the package.
+---
+
+The web collection is a set of React components for dashboards and product pages: a globe that
+turns under the pointer, numbers that roll to their new value, cards that react to the cursor.
+
+They are styled with Tailwind CSS on the theme tokens a shadcn project already defines, so a
+component takes your colours and your radius without any configuration of its own.
+
+
+ `panelui-native` is for Expo and React Native, and has its own components and its own
+ installation. These are for React in a browser. Use the **App** tab at the top of the sidebar
+ for the app library.
+
+
+## What you need
+
+- **React 18** or newer.
+- **Tailwind CSS 4**.
+- **A project set up with shadcn**, which gives you the theme tokens and a `components.json`.
+ [Installation](/docs/web/installation) covers this for each framework.
+
+## Two ways to add a component
+
+**Copy it into your project** with the shadcn CLI. The source lands in `components/panelui/`,
+and from then on it is your file:
+
+
+
+**Or install the package** and import from it:
+
+
+
+Both come from the same source, so a component looks and behaves the same either way. Copy a
+component when you expect to change it. Install the package when you would rather get fixes by
+upgrading.
+
+
+
+ Set up a new or existing project, by framework.
+
+
+ Every component, running, with its source.
+
+
diff --git a/apps/docs/content/docs/web/installation/astro.mdx b/apps/docs/content/docs/web/installation/astro.mdx
new file mode 100644
index 00000000..cbdec29d
--- /dev/null
+++ b/apps/docs/content/docs/web/installation/astro.mdx
@@ -0,0 +1,72 @@
+---
+title: Astro
+description: Add PanelUI web components to an Astro site with React.
+---
+
+For an Astro site with the React integration. The components are React, so they run as islands.
+
+## A new project
+
+
+
+
+
+### Create the project
+
+The shadcn CLI creates the app, sets up Tailwind CSS and writes the theme tokens in one go:
+
+
+
+
+
+
+
+### Add a component
+
+
+
+The source is written to `components/panelui/rolling-number.tsx`, and the packages it uses are
+installed for you.
+
+
+
+
+
+### Use it
+
+```astro title="src/pages/index.astro"
+---
+import Layout from "@/layouts/main.astro"
+import { RollingNumber } from "@/components/panelui/rolling-number"
+---
+
+
+
+
+
+
+```
+
+
+
+
+
+## An existing project
+
+The project needs the React integration and Tailwind CSS. Add React with `npx astro add react` if it is missing. Then:
+
+
+
+Then add a component as in the steps above.
+
+## Islands
+
+Astro renders a React component to HTML and sends no JavaScript for it unless told to. A
+component that animates or responds to the pointer needs a `client:` directive: `client:load`
+to start at once, or `client:visible` to wait until it is scrolled into view. Without one it is
+drawn once and never moves.
+
+## From npm
+
+To install the package instead of copying source, follow
+[From npm](/docs/web/installation#from-npm) once the project has its shadcn theme.
diff --git a/apps/docs/content/docs/web/installation/index.mdx b/apps/docs/content/docs/web/installation/index.mdx
new file mode 100644
index 00000000..16d9d881
--- /dev/null
+++ b/apps/docs/content/docs/web/installation/index.mdx
@@ -0,0 +1,74 @@
+---
+title: Installation
+description: Add PanelUI's web components to a React project, from the registry with one command or as an npm package.
+---
+
+The web collection is built on shadcn. A project needs **Tailwind CSS 4** and the **shadcn theme
+tokens**. After that, a component is one command.
+
+## Choose your framework
+
+Each guide starts from nothing and ends with a component on the page. If your project already
+uses shadcn, skip to [Add a component](#add-a-component).
+
+
+
+## Add a component
+
+In a project that already has a `components.json`, copy any component in with the shadcn CLI:
+
+
+
+The CLI writes the source to `components/panelui/`, installs the packages it uses, and adds any
+other PanelUI component it is built on. Import it from there:
+
+```tsx
+import { RollingNumber } from "@/components/panelui/rolling-number"
+```
+
+The address of every component is on its own page, under **Installation**.
+
+### A shorter address
+
+Register the collection once in `components.json` and the CLI takes a name instead of a URL:
+
+```json title="components.json"
+{
+ "registries": {
+ "@panelui": "https://panelui.dev/r/web/{name}.json"
+ }
+}
+```
+
+
+
+## From npm
+
+Install the package instead of copying source, and upgrade it like any other dependency:
+
+
+
+Tailwind only compiles the classes it can see, and it does not look inside `node_modules`. Point
+it at the package once, in the CSS file that imports Tailwind:
+
+```css title="app/globals.css"
+@import "tailwindcss";
+@source "../node_modules/panelui-web/dist";
+```
+
+The path is relative to the CSS file. Then import each component from its own entry:
+
+```tsx
+import { RollingNumber } from "panelui-web/rolling-number"
+```
+
+The package still needs the shadcn theme tokens, so run `shadcn init` in the project first. If a
+component renders unstyled, the `@source` line is missing or points at the wrong folder.
+
+## Without shadcn
+
+The components need these CSS variables and nothing else from shadcn: `--background`,
+`--foreground`, `--card`, `--popover`, `--primary`, `--secondary`, `--muted`, `--accent`,
+`--border`, `--input` and `--ring`, with the `-foreground` partner of each surface. Define them
+yourself and map them into Tailwind's theme, and the components work in a project that has never
+run the CLI. [Manual installation](/docs/web/installation/manual) shows the whole block.
diff --git a/apps/docs/content/docs/web/installation/laravel.mdx b/apps/docs/content/docs/web/installation/laravel.mdx
new file mode 100644
index 00000000..8b9f567f
--- /dev/null
+++ b/apps/docs/content/docs/web/installation/laravel.mdx
@@ -0,0 +1,68 @@
+---
+title: Laravel
+description: Add PanelUI web components to a Laravel app with the React starter kit.
+---
+
+For Laravel with Inertia and React.
+
+## A new project
+
+
+
+
+
+### Create the project
+
+Create the app with the React starter kit, then run the shadcn CLI inside it:
+
+```bash
+laravel new my-app
+```
+
+
+
+
+
+
+
+### Add a component
+
+
+
+The source is written to `components/panelui/rolling-number.tsx`, and the packages it uses are
+installed for you.
+
+
+
+
+
+### Use it
+
+```tsx title="resources/js/pages/index.tsx"
+import { RollingNumber } from "@/components/panelui/rolling-number"
+
+export default function Index() {
+ return (
+
+
+
+ )
+}
+```
+
+
+
+
+
+## An existing project
+
+Run the CLI in the project. It adds the theme tokens to your CSS and writes `components.json`:
+
+
+
+Then add a component as in the steps above.
+
+## From npm
+
+To install the package instead of copying source, follow
+[From npm](/docs/web/installation#from-npm) once the project has its shadcn theme.
diff --git a/apps/docs/content/docs/web/installation/manual.mdx b/apps/docs/content/docs/web/installation/manual.mdx
new file mode 100644
index 00000000..ad012f23
--- /dev/null
+++ b/apps/docs/content/docs/web/installation/manual.mdx
@@ -0,0 +1,138 @@
+---
+title: Manual
+description: Add PanelUI web components to any React project with Tailwind CSS, without the shadcn CLI.
+---
+
+For a React project the CLI does not support, or one where you would rather set things up by
+hand. You need Tailwind CSS 4 already working.
+
+
+
+
+
+### Add the theme tokens
+
+The components are styled with these variables. Put them in the CSS file that imports Tailwind,
+and change the values to suit your design:
+
+```css title="src/styles/globals.css"
+@import "tailwindcss";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+```
+
+Dark mode follows a `dark` class on an ancestor, usually ``.
+
+
+
+
+
+### Install the package
+
+
+
+Tell Tailwind to read its classes, in the same CSS file. The path is relative to that file:
+
+```css title="src/styles/globals.css"
+@source "../../node_modules/panelui-web/dist";
+```
+
+
+
+
+
+### Use a component
+
+```tsx
+import { RollingNumber } from "panelui-web/rolling-number"
+
+export function Events() {
+ return
+}
+```
+
+
+
+
+
+## Copying the source instead
+
+Every component page has a **Manual** tab with the source and the packages it needs. The copied
+file imports `cn` from `@/lib/utils`, so add that helper and the alias if your project has
+neither:
+
+
+
+```ts title="lib/utils.ts"
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+```
diff --git a/apps/docs/content/docs/web/installation/meta.json b/apps/docs/content/docs/web/installation/meta.json
new file mode 100644
index 00000000..fd3e8ac0
--- /dev/null
+++ b/apps/docs/content/docs/web/installation/meta.json
@@ -0,0 +1,13 @@
+{
+ "title": "Installation",
+ "pages": [
+ "index",
+ "next",
+ "vite",
+ "tanstack-start",
+ "react-router",
+ "astro",
+ "laravel",
+ "manual"
+ ]
+}
diff --git a/apps/docs/content/docs/web/installation/next.mdx b/apps/docs/content/docs/web/installation/next.mdx
new file mode 100644
index 00000000..4a22b369
--- /dev/null
+++ b/apps/docs/content/docs/web/installation/next.mdx
@@ -0,0 +1,77 @@
+---
+title: Next.js
+description: Add PanelUI web components to a Next.js app.
+---
+
+For the App Router. The components are Client Components, and a Server Component page can render them.
+
+## A new project
+
+
+
+
+
+### Create the project
+
+The shadcn CLI creates the app, sets up Tailwind CSS and writes the theme tokens in one go:
+
+
+
+
+
+
+
+### Add a component
+
+
+
+The source is written to `components/panelui/rolling-number.tsx`, and the packages it uses are
+installed for you.
+
+
+
+
+
+### Use it
+
+```tsx title="app/page.tsx"
+import { RollingNumber } from "@/components/panelui/rolling-number"
+
+export default function Home() {
+ return (
+
+
+
+ )
+}
+```
+
+
+
+
+
+## An existing project
+
+Run the CLI in the project. It adds the theme tokens to your CSS and writes `components.json`:
+
+
+
+Then add a component as in the steps above.
+
+## Server Components
+
+A component that holds state starts with `"use client"`, so it runs in the browser and can be
+rendered from a Server Component without a wrapper.
+
+Two things need a Client Component of your own:
+
+- **A value that changes.** State lives in the browser, so the part of the page that holds it
+ needs `"use client"`.
+- **Dotted parts.** A Server Component cannot read `Planet.Globe` off a client import. Either put
+ `"use client"` at the top of the file, or import the part by name: every `Planet.Globe` is
+ also exported as `PlanetGlobe`. Each component's page lists its names under **Notes**.
+
+## From npm
+
+To install the package instead of copying source, follow
+[From npm](/docs/web/installation#from-npm) once the project has its shadcn theme.
diff --git a/apps/docs/content/docs/web/installation/react-router.mdx b/apps/docs/content/docs/web/installation/react-router.mdx
new file mode 100644
index 00000000..107ba738
--- /dev/null
+++ b/apps/docs/content/docs/web/installation/react-router.mdx
@@ -0,0 +1,64 @@
+---
+title: React Router
+description: Add PanelUI web components to a React Router app.
+---
+
+For React Router in framework mode. Its default import alias is `~`, so imports start with `~/` where the other guides have `@/`.
+
+## A new project
+
+
+
+
+
+### Create the project
+
+The shadcn CLI creates the app, sets up Tailwind CSS and writes the theme tokens in one go:
+
+
+
+
+
+
+
+### Add a component
+
+
+
+The source is written to `components/panelui/rolling-number.tsx`, and the packages it uses are
+installed for you.
+
+
+
+
+
+### Use it
+
+```tsx title="app/routes/home.tsx"
+import { RollingNumber } from "~/components/panelui/rolling-number"
+
+export default function Home() {
+ return (
+
+
+
+ )
+}
+```
+
+
+
+
+
+## An existing project
+
+Run the CLI in the project. It adds the theme tokens to your CSS and writes `components.json`:
+
+
+
+Then add a component as in the steps above.
+
+## From npm
+
+To install the package instead of copying source, follow
+[From npm](/docs/web/installation#from-npm) once the project has its shadcn theme.
diff --git a/apps/docs/content/docs/web/installation/tanstack-start.mdx b/apps/docs/content/docs/web/installation/tanstack-start.mdx
new file mode 100644
index 00000000..4e58586f
--- /dev/null
+++ b/apps/docs/content/docs/web/installation/tanstack-start.mdx
@@ -0,0 +1,67 @@
+---
+title: TanStack Start
+description: Add PanelUI web components to a TanStack Start app.
+---
+
+For TanStack Start, with file-based routes.
+
+## A new project
+
+
+
+
+
+### Create the project
+
+The shadcn CLI creates the app, sets up Tailwind CSS and writes the theme tokens in one go:
+
+
+
+
+
+
+
+### Add a component
+
+
+
+The source is written to `components/panelui/rolling-number.tsx`, and the packages it uses are
+installed for you.
+
+
+
+
+
+### Use it
+
+```tsx title="src/routes/index.tsx"
+import { createFileRoute } from "@tanstack/react-router"
+import { RollingNumber } from "@/components/panelui/rolling-number"
+
+export const Route = createFileRoute("/")({ component: Home })
+
+function Home() {
+ return (
+
+
+
+ )
+}
+```
+
+
+
+
+
+## An existing project
+
+Run the CLI in the project. It adds the theme tokens to your CSS and writes `components.json`:
+
+
+
+Then add a component as in the steps above.
+
+## From npm
+
+To install the package instead of copying source, follow
+[From npm](/docs/web/installation#from-npm) once the project has its shadcn theme.
diff --git a/apps/docs/content/docs/web/installation/vite.mdx b/apps/docs/content/docs/web/installation/vite.mdx
new file mode 100644
index 00000000..02d19470
--- /dev/null
+++ b/apps/docs/content/docs/web/installation/vite.mdx
@@ -0,0 +1,64 @@
+---
+title: Vite
+description: Add PanelUI web components to a Vite and React app.
+---
+
+For a single-page React app built with Vite.
+
+## A new project
+
+
+
+
+
+### Create the project
+
+The shadcn CLI creates the app, sets up Tailwind CSS and writes the theme tokens in one go:
+
+
+
+
+
+
+
+### Add a component
+
+
+
+The source is written to `components/panelui/rolling-number.tsx`, and the packages it uses are
+installed for you.
+
+
+
+
+
+### Use it
+
+```tsx title="src/App.tsx"
+import { RollingNumber } from "@/components/panelui/rolling-number"
+
+export default function App() {
+ return (
+
+
+
+ )
+}
+```
+
+
+
+
+
+## An existing project
+
+A Vite project needs Tailwind CSS and the `@` import alias before the CLI can run. If yours has neither, start from the template above, or set them up as the Tailwind and Vite documentation describe. Then:
+
+
+
+Then add a component as in the steps above.
+
+## From npm
+
+To install the package instead of copying source, follow
+[From npm](/docs/web/installation#from-npm) once the project has its shadcn theme.
diff --git a/apps/docs/content/docs/web/mcp.mdx b/apps/docs/content/docs/web/mcp.mdx
new file mode 100644
index 00000000..6f756589
--- /dev/null
+++ b/apps/docs/content/docs/web/mcp.mdx
@@ -0,0 +1,72 @@
+---
+title: MCP
+description: Let a coding agent look the web components up in the registry, and add them, through the shadcn MCP server.
+---
+
+The web collection is a shadcn registry, so the MCP server for it is shadcn's own. Pointed at the
+registry, it lets an agent list the components, read one in full with its source, and get the
+command that adds it, without leaving the editor.
+
+It reads the registry. It does not know your project's conventions, which is what the
+[skill](/docs/web/skills) is for. Most projects want both.
+
+## Add the registry
+
+The server reads the registries named in `components.json`. Add PanelUI's once:
+
+```json title="components.json"
+{
+ "registries": {
+ "@panelui": "https://panelui.dev/r/web/{name}.json"
+ }
+}
+```
+
+That is also what lets you add a component by name:
+
+
+
+## Start the server
+
+
+
+`--client` is `claude`, `cursor`, `vscode`, `codex` or `opencode`. The command writes the server
+into that client's config file and merges with whatever is there.
+
+By hand, it is:
+
+```json title=".mcp.json"
+{
+ "mcpServers": {
+ "shadcn": {
+ "command": "npx",
+ "args": ["shadcn@latest", "mcp"]
+ }
+ }
+}
+```
+
+| Client | Config file |
+| --- | --- |
+| Claude Code | `.mcp.json` |
+| Cursor | `.cursor/mcp.json` |
+| VS Code | `.vscode/mcp.json` |
+
+Restart the client after adding it.
+
+## What to ask
+
+The server answers for every registry in `components.json`, so name PanelUI when you mean it.
+
+- "List the components in the @panelui registry."
+- "Show me the KPI component from @panelui and its props."
+- "Add the Lane Board from @panelui and put my tasks in it."
+
+## Without the server
+
+The same lookups are CLI commands, and an agent that can run a shell can use them:
+
+```bash
+npx shadcn@latest search @panelui
+npx shadcn@latest view @panelui/kpi
+```
diff --git a/apps/docs/content/docs/web/meta.json b/apps/docs/content/docs/web/meta.json
new file mode 100644
index 00000000..8220c62d
--- /dev/null
+++ b/apps/docs/content/docs/web/meta.json
@@ -0,0 +1,17 @@
+{
+ "title": "Web",
+ "description": "Components for React, built on shadcn",
+ "root": true,
+ "pages": [
+ "---Get started---",
+ "index",
+ "components/index",
+ "...installation",
+ "mcp",
+ "skills",
+ "style",
+ "theme",
+ "---Components---",
+ "...components"
+ ]
+}
diff --git a/apps/docs/content/docs/web/skills.mdx b/apps/docs/content/docs/web/skills.mdx
new file mode 100644
index 00000000..bad55eed
--- /dev/null
+++ b/apps/docs/content/docs/web/skills.mdx
@@ -0,0 +1,47 @@
+---
+title: Skills
+description: Give a coding agent working knowledge of the web collection, so it uses the components and the theme's colours and does not invent its own.
+---
+
+An agent working in your project does not know the web collection is there. It writes a custom
+chart where there is a KPI, guesses at props, and gives every component the same colour. The
+skill is a folder of markdown the agent loads when it recognises a project that uses the
+collection: what exists, every part and prop, how colour works, and how to install a component.
+
+It carries the props themselves, read from the source, so it works for an agent with no network
+access. For looking things up live in the registry, add the [MCP server](/docs/web/mcp) as well.
+
+## Install
+
+
+
+That writes the skill into your project for whichever agent you use. Run it again to update.
+Leave out `--skill` to be offered every skill in the repository, which includes the one for the
+React Native library.
+
+## What is in it
+
+| File | What it holds |
+| --- | --- |
+| `SKILL.md` | When the skill applies, and the rules: colour is a role, parts in Server Components, lead with a frame |
+| `components.md` | Every component, with a line on what it is for |
+| `components/.md` | One component in full: import, when to use it, parts, every prop, notes and a worked example |
+| `setup.md` | Installing from the registry or from npm |
+| `theming.md` | The five colour roles and the three tokens a stock theme lacks |
+
+The component files are generated from the same source as these pages, so a prop described here
+is described the same way there.
+
+## When it applies
+
+The skill activates on its own in a React project that has a `@panelui` registry in
+`components.json` or `panelui-web` in its dependencies, and when you ask for something the
+collection has, such as a globe, a KPI card or a board of lanes.
+
+## Asking for things
+
+Name what you want and let the agent find the component:
+
+- "Show this week's deploys with one day picked."
+- "Put the pipeline stages on a board I can drag cards across."
+- "Add a KPI row for subscribers, with the trend. Fewer unsubscribes is good."
diff --git a/apps/docs/content/docs/web/style.mdx b/apps/docs/content/docs/web/style.mdx
new file mode 100644
index 00000000..3ed23862
--- /dev/null
+++ b/apps/docs/content/docs/web/style.mdx
@@ -0,0 +1,78 @@
+---
+title: Style
+description: Changing how a web component looks, with className on every part, the colour prop, the variables meant to be set, and the frame.
+---
+
+The components are styled with Tailwind classes on your theme's tokens. There are four ways to
+change how one looks, from the lightest touch to the heaviest.
+
+## The colour prop
+
+Every component with a colour of its own takes `color`, one of the theme's five roles:
+
+```tsx
+
+
+```
+
+`primary` is the default. Pick the role by what the colour means, and compute it where it
+depends on a value. The roles and the tokens behind them are on the [Theme](/docs/web/theme) page.
+
+## className
+
+Every part takes `className`, and it is merged over the part's own classes, so a class you pass
+wins over the one it replaces:
+
+```tsx
+
+
+
+
+
+```
+
+Use it for size, spacing and layout. Do not use it to recolour a component with a palette class
+such as `bg-emerald-500`: that stops following the theme. Change the role or the token.
+
+## Variables meant to be set
+
+A few measurements are held in a CSS variable so they can be changed without rewriting the
+component. Set one with an arbitrary property class:
+
+```tsx
+
+```
+
+Each component's page lists the variables it reads, under Notes. A variable that is not listed
+there is internal and may change.
+
+## Editing the source
+
+Installed from the registry, a component is a file in `components/panelui/`, and it is yours.
+Open it and change it. Run the CLI with `--diff` before updating to see what an update would
+overwrite:
+
+
+
+Installed from npm, the source is in the package and cannot be edited. Use the three ways above,
+or switch that component to the registry.
+
+## The frame
+
+The demos on these pages put a component in a frame: a muted band with a title, round an inner
+bordered panel. It is plain markup, not a component:
+
+```tsx
+
+
+
+ {/* the component */}
+
+
+```
+
+The inner radius is the outer one less the padding between them, which keeps the two curves
+concentric.
diff --git a/apps/docs/content/docs/web/theme.mdx b/apps/docs/content/docs/web/theme.mdx
new file mode 100644
index 00000000..b220416d
--- /dev/null
+++ b/apps/docs/content/docs/web/theme.mdx
@@ -0,0 +1,77 @@
+---
+title: Theme
+description: The tokens the web components read, the three a stock shadcn theme does not have, and how light and dark work.
+---
+
+The web components have no theme of their own. They read the tokens your shadcn theme already
+defines, so they match the rest of your interface and change with it.
+
+## The five colour roles
+
+A component's colour is a role, passed as `color`. Each role is one token:
+
+| Role | Token | Use it for |
+| --- | --- | --- |
+| `primary` | `--primary` | The default. Your theme's main colour |
+| `info` | `--info` | Neutral emphasis: worth noticing, neither good nor bad |
+| `success` | `--success` | Good news, progress, a healthy state |
+| `warning` | `--warning` | Something that wants attention before it is a problem |
+| `destructive` | `--destructive` | Bad news, a failure, a limit passed |
+
+## Three tokens a stock theme lacks
+
+`--primary` and `--destructive` are in every shadcn theme. `--success`, `--warning` and `--info`
+are not, and a component drawn in a role whose token is missing has no colour where the colour
+should be.
+
+Installed from the registry, a component that uses one of them brings it: the CLI adds the tokens
+to your stylesheet and leaves any you have already defined alone.
+
+Installed from npm, add them yourself:
+
+```css title="globals.css"
+:root {
+ --success: oklch(0.696 0.17 162.48);
+ --warning: oklch(0.769 0.188 70.08);
+ --info: oklch(0.623 0.214 259.815);
+}
+```
+
+They are mid-tones that read on a light and a dark background, so one value serves both. Give
+`.dark` its own values if your theme wants them.
+
+## Surfaces
+
+These come from your theme as it is, and every shadcn theme has them:
+
+| Token | Where the components use it |
+| --- | --- |
+| `--background`, `--foreground` | The page, and text on it |
+| `--card`, `--card-foreground` | Cards, nodes, panels |
+| `--muted`, `--muted-foreground` | Trays, tracks, quiet text, unlit marks |
+| `--accent` | Hover and focus fills |
+| `--border`, `--ring` | Hairlines and focus rings |
+
+## Light and dark
+
+There is nothing to switch on. The components read tokens, and your theme changes the tokens.
+Planet draws on a canvas and reads its colours from the page each time it draws, so it follows
+a theme change too.
+
+## Changing a colour everywhere
+
+Change the token. To make every `success` in the collection your brand's green:
+
+```css
+:root {
+ --success: oklch(0.72 0.19 150);
+}
+```
+
+To change one component, use its `color` prop or the ways on the [Style](/docs/web/style) page.
+
+## Matching PanelUI's own look
+
+The pages here are drawn in PanelUI's theme: a neutral `--primary` that is near black in light
+mode and near white in dark, with tints of 4% for `--muted` and `--accent`. The components do
+not depend on it. In your project they are drawn in your theme.
diff --git a/apps/docs/lib/skills.generated.json b/apps/docs/lib/skills.generated.json
index 488789d2..8edfe250 100644
--- a/apps/docs/lib/skills.generated.json
+++ b/apps/docs/lib/skills.generated.json
@@ -12,5 +12,12 @@
"description": "Build React Native and Expo UIs with PanelUI — adding components, theming, composing overlays and forms, and debugging why something is unstyled. Applies when working in an Expo or React Native project that has panelui-native installed or a panelui.json, when the user mentions PanelUI, or when they ask for a bottom sheet, dialog, chart, chat transcript, form or any other screen UI in React Native.",
"path": "/skills/panelui/SKILL.md",
"digest": "sha256:175340c59851e14af146b174ea14d62c3167173be24e6b24789e426705ef775f"
+ },
+ {
+ "name": "panelui-web",
+ "type": "skill-md",
+ "description": "Build React web UIs with PanelUI's web collection — adding its components from the shadcn registry or from npm, colouring them from the theme, and composing them into framed panels. Applies when working in a React project that has shadcn/ui set up with a @panelui registry or has panelui-web installed, when the user mentions PanelUI on the web, or when they ask for a dotted globe, a KPI card, a kanban board, a node graph, an activity grid or a feedback dialog in React.",
+ "path": "/skills/panelui-web/SKILL.md",
+ "digest": "sha256:3adccefe8cb2fd23660174795faa907bc9c4bbd675c6e89affc32a4ba7a95acc"
}
]
diff --git a/apps/docs/mdx-components.tsx b/apps/docs/mdx-components.tsx
index 32a84361..478cd44a 100644
--- a/apps/docs/mdx-components.tsx
+++ b/apps/docs/mdx-components.tsx
@@ -3,12 +3,16 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { ComponentGallery } from '@/components/component-gallery';
+import { WebComponentGallery } from '@/components/web-component-gallery';
+import { ComponentPreview } from '@/components/component-preview';
import { Diagram } from '@/components/diagram';
+import { FrameworkGrid } from '@/components/framework-grid';
import { IconGallery } from '@/components/icon-gallery';
import { InstallTabs } from '@/components/install-tabs';
import { Preview } from '@/components/preview';
import { Platform } from '@/components/platform';
import { PreviewVideo } from '@/components/preview-video';
+import { RegistrySource } from '@/components/registry-source';
import type { MDXComponents } from 'mdx/types';
/** Components available to every MDX page without importing them. */
@@ -19,13 +23,17 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
Tabs,
Callout,
ComponentGallery,
+ WebComponentGallery,
+ ComponentPreview,
Step,
Steps,
Diagram,
+ FrameworkGrid,
IconGallery,
InstallTabs,
Preview,
PreviewVideo,
+ RegistrySource,
Platform,
...components,
};
diff --git a/apps/docs/package.json b/apps/docs/package.json
index ab8c097e..df91899a 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -10,9 +10,9 @@
"typecheck": "tsc --noEmit",
"test": "node --experimental-strip-types --test test/*.test.mjs",
"test:a11y:browser": "node test/accessibility-smoke.mjs",
- "docs:generate": "node scripts/extract.mjs && node scripts/gen.mjs && node scripts/extract-icons.mjs && node scripts/build-registry.mjs && node scripts/gen-blocks.mjs && node scripts/build-api-reference.mjs && node scripts/build-skill.mjs && node scripts/build-skill-assets.mjs && node ../../scripts/theme-manifest.mjs && node ../../scripts/catalogue-manifest.mjs",
- "registry:build": "node scripts/build-registry.mjs",
- "prebuild": "node scripts/extract.mjs && node scripts/extract-icons.mjs && node scripts/build-registry.mjs && node scripts/build-api-reference.mjs --check && node scripts/build-skill.mjs && node scripts/build-skill-assets.mjs"
+ "docs:generate": "node scripts/extract.mjs && node scripts/gen.mjs && node scripts/extract-icons.mjs && node scripts/build-registry.mjs && node scripts/build-web-registry.mjs && node scripts/gen-web.mjs && node scripts/gen-blocks.mjs && node scripts/build-api-reference.mjs && node scripts/build-skill.mjs && node scripts/build-web-skill.mjs && node scripts/build-skill-assets.mjs && node ../../scripts/theme-manifest.mjs && node ../../scripts/catalogue-manifest.mjs",
+ "registry:build": "node scripts/build-registry.mjs && node scripts/build-web-registry.mjs",
+ "prebuild": "node scripts/extract.mjs && node scripts/extract-icons.mjs && node scripts/build-registry.mjs && node scripts/build-web-registry.mjs && node scripts/build-api-reference.mjs --check && node scripts/build-skill.mjs && node scripts/build-web-skill.mjs && node scripts/build-skill-assets.mjs"
},
"//browserslist": "Next's default target still down-levels Array.prototype.at, Object.hasOwn and friends, shipping ~14KB of polyfills for engines that have had them since 2021. This is a docs site for a React Native library — its readers are on current browsers.",
"browserslist": [
diff --git a/apps/docs/public/r/web/bar-picker.json b/apps/docs/public/r/web/bar-picker.json
new file mode 100644
index 00000000..f42053ab
--- /dev/null
+++ b/apps/docs/public/r/web/bar-picker.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "bar-picker",
+ "type": "registry:component",
+ "title": "Bar Picker",
+ "description": "A short run of values as columns of lights, one column picked.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/bar-picker.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/bar-picker.tsx",
+ "content": "'use client';\n\n/**\n * BarPicker — a short run of values as columns of lights, one column picked.\n *\n * ```tsx\n * \n * ```\n *\n * ## A column is a stack of lights\n *\n * Each value is drawn as a column of segments, lit from the bottom up to the\n * value, the way a level meter shows loudness. The unlit segments stay as\n * faint outlines of themselves, so every column is the same height and how\n * full it is can be judged against its own top without an axis.\n *\n * ## The picked column is the bright one\n *\n * It is lit at full strength with a glow on its highest segment, and it is\n * wider than the others. The rest are the same colour, held back. Picking\n * another column lights it from the bottom, one segment after the next.\n *\n * ## A choice, not a chart\n *\n * It behaves like a row of radio buttons. Exactly one column is picked, and\n * the arrow keys walk along the row. The figure for the picked column is\n * yours to show beside it, large: a column of eight lights cannot be read to\n * the unit, but one number can.\n */\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\n\nexport interface BarPickerBar {\n /** Written under the column: \"Mon\", \"W3\". Keep it to a few characters. */\n label: ReactNode;\n value: number;\n /** The long form, for a screen reader: \"Monday\". Defaults to the label. */\n name?: string;\n}\n\n/** Which of the theme's roles the lights are drawn in. */\nexport type BarPickerColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';\n\nconst barPickerVariants = tv({\n slots: {\n root: 'flex w-full items-end gap-1.5',\n column:\n 'group flex min-w-0 cursor-pointer flex-col items-stretch gap-2.5 rounded-lg outline-none transition-[flex-grow] duration-500 ease-[cubic-bezier(0.34,1.4,0.64,1)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background motion-reduce:transition-none',\n // Stacked from the bottom, so the first segment in the markup is the\n // lowest light.\n stack: 'flex h-28 flex-col-reverse gap-[3px]',\n segment:\n 'min-h-0 flex-1 rounded-[5px] transition-[background-color,box-shadow,opacity] duration-300 motion-reduce:transition-none',\n label: 'truncate text-center text-xs text-muted-foreground transition-colors',\n },\n variants: {\n color: {\n primary: { root: '[--pu-color:var(--primary)]' },\n info: { root: '[--pu-color:var(--info)]' },\n success: { root: '[--pu-color:var(--success)]' },\n warning: { root: '[--pu-color:var(--warning)]' },\n destructive: { root: '[--pu-color:var(--destructive)]' },\n },\n selected: {\n true: { column: 'flex-[1.5]', label: 'font-medium text-foreground' },\n false: { column: 'flex-1' },\n },\n /** What one segment is doing. */\n light: {\n off: { segment: 'bg-muted' },\n // Lit in a column that is not picked: the colour, held back.\n dim: {\n segment:\n 'bg-[color-mix(in_oklab,var(--pu-color)_26%,transparent)] group-hover:bg-[color-mix(in_oklab,var(--pu-color)_48%,transparent)]',\n },\n on: { segment: 'bg-[var(--pu-color)]' },\n // The highest light of the picked column.\n peak: {\n segment:\n 'bg-[var(--pu-color)] shadow-[0_0_16px_color-mix(in_oklab,var(--pu-color)_65%,transparent)]',\n },\n },\n },\n});\n\nexport interface BarPickerProps\n extends Omit, 'children' | 'onChange' | 'defaultValue' | 'color'> {\n /** The columns, left to right. */\n data: BarPickerBar[];\n /** The value a fully lit column stands for. Defaults to the largest in `data`. */\n max?: number;\n /** The index of the picked column, when the caller holds it. */\n value?: number;\n /** The index picked to begin with, when the caller does not hold it. */\n defaultValue?: number;\n /** Called with the index of a column when it is picked. */\n onValueChange?: (index: number) => void;\n /** How many lights a column is made of. */\n segments?: number;\n /** Which of the theme's roles the lights are drawn in. */\n color?: BarPickerColor;\n /** Writes a value for a screen reader: \"17 deploys\". */\n formatValue?: (value: number) => string;\n /** What a screen reader calls the row. */\n label?: string;\n}\n\n/** Milliseconds between one light of a column coming on and the next. */\nconst STAGGER = 32;\n\nexport function BarPicker({\n data,\n max,\n value,\n defaultValue = 0,\n onValueChange,\n segments = 8,\n color = 'primary',\n formatValue = (bar) => String(bar),\n label = 'Values',\n className,\n ...props\n}: BarPickerProps) {\n const slots = barPickerVariants({ color });\n const [internal, setInternal] = useState(defaultValue);\n const picked = clampIndex(value ?? internal, data.length);\n const refs = useRef<(HTMLButtonElement | null)[]>([]);\n // The columns start dark and light up once mounted, so they are seen\n // arriving.\n const [lit, setLit] = useState(false);\n useEffect(() => {\n const frame = requestAnimationFrame(() => setLit(true));\n return () => cancelAnimationFrame(frame);\n }, []);\n\n const count = Math.max(1, Math.round(segments));\n const ceiling = max ?? Math.max(1, ...data.map((bar) => bar.value));\n\n const pick = (index: number) => {\n if (value === undefined) setInternal(index);\n onValueChange?.(index);\n };\n\n const onKeyDown = (event: KeyboardEvent, index: number) => {\n const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';\n const forward = rtl ? -1 : 1;\n const moves: Record = {\n ArrowLeft: index - forward,\n ArrowRight: index + forward,\n ArrowDown: index - 1,\n ArrowUp: index + 1,\n Home: 0,\n End: data.length - 1,\n };\n const target = moves[event.key];\n if (target === undefined) return;\n event.preventDefault();\n // Round the ends, as a radio group does.\n const next = (target + data.length) % data.length;\n refs.current[next]?.focus();\n pick(next);\n };\n\n return (\n \n {data.map((bar, index) => {\n const selected = index === picked;\n const part = barPickerVariants({ selected });\n const name = bar.name ?? (typeof bar.label === 'string' ? bar.label : `Bar ${index + 1}`);\n // How many lights the value reaches. Any value above zero lights at\n // least one, so a small value is a mark and not an empty column.\n const share = Math.min(1, Math.max(0, bar.value / ceiling));\n const reached = !lit || bar.value <= 0 ? 0 : Math.max(1, Math.round(share * count));\n return (\n {\n refs.current[index] = node;\n }}\n type=\"button\"\n role=\"radio\"\n aria-checked={selected}\n aria-label={`${name}: ${formatValue(bar.value)}`}\n tabIndex={selected ? 0 : -1}\n onClick={() => pick(index)}\n onKeyDown={(event) => onKeyDown(event, index)}\n className={part.column()}\n >\n \n {Array.from({ length: count }, (_, level) => {\n const on = level < reached;\n const light = !on ? 'off' : !selected ? 'dim' : level === reached - 1 ? 'peak' : 'on';\n return (\n \n );\n })}\n \n \n {bar.label}\n \n \n );\n })}\n
\n );\n}\nBarPicker.displayName = 'BarPicker';\n\nfunction clampIndex(index: number, length: number): number {\n if (length === 0) return 0;\n return Math.min(length - 1, Math.max(0, index));\n}\n"
+ }
+ ]
+}
diff --git a/apps/docs/public/r/web/command-dock.json b/apps/docs/public/r/web/command-dock.json
new file mode 100644
index 00000000..4636ede2
--- /dev/null
+++ b/apps/docs/public/r/web/command-dock.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "command-dock",
+ "type": "registry:component",
+ "title": "Command Dock",
+ "description": "A small button that opens into a field, with the answer arriving under it a row at a time.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "files": [
+ {
+ "path": "components/panelui/command-dock.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/command-dock.tsx",
+ "content": "'use client';\n\n/**\n * CommandDock — a small button that opens into a place to ask for something,\n * with the answer arriving under it a row at a time.\n *\n * ```tsx\n * \n * Ask \n * \n * \n * \n * {rows.map((row) => {row.text} )}\n * \n * \n * \n * ```\n *\n * ## One surface that opens\n *\n * The button and the panel are the same surface. Closed, it is a pill as\n * wide as its label. Opened, it widens, the label fades into a field in the\n * same place, and it grows downwards as rows arrive. Closing runs the same\n * steps backwards: the rows fold up, then it narrows. Nothing appears from\n * elsewhere, so it is clear what was pressed and where the answer belongs.\n *\n * ## It runs nothing\n *\n * The dock does not know where answers come from. `onSubmit` is handed what\n * was typed, and the rows are whatever you render. A row that mounts slides\n * in after the one before it, so rows added one by one read as an answer\n * being written, whether they come from a search, a model or a list you\n * already had.\n *\n * ## Closing\n *\n * Escape closes it and gives focus back to where it was, and so does a press\n * anywhere outside it.\n */\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type FormEvent,\n type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\n\nconst commandDockVariants = tv({\n slots: {\n root: 'relative flex w-full justify-center',\n /*\n * The widths are both real lengths, so the change between them can be\n * animated; `auto` cannot. The easing has no overshoot: a surface this\n * size that bounces as it closes reads as a mistake.\n */\n surface:\n 'overflow-hidden border bg-card text-card-foreground shadow-sm transition-[width,border-radius,box-shadow] duration-[400ms] ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',\n // The label keeps its row whether or not it can be seen, so the surface\n // is never shorter than one row and never jumps as the two trade places.\n trigger:\n 'flex h-11 w-full cursor-pointer items-center justify-center gap-2 whitespace-nowrap px-4 text-sm font-medium outline-none transition-[opacity,background-color] duration-200 hover:bg-accent focus-visible:bg-accent [&_svg]:size-4 [&_svg]:shrink-0',\n // Pulled up over the label's row: the field sits exactly where the label\n // was, and the two fade through each other.\n panel: '-mt-11 flex flex-col',\n form: 'flex h-11 items-center gap-2 pe-1.5 ps-4 transition-opacity duration-200 motion-reduce:transition-none',\n input:\n 'h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground',\n submit:\n 'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground outline-none transition-[opacity,scale] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card active:scale-95 disabled:cursor-default disabled:opacity-30 [&_svg]:size-4',\n // Two rows of a grid, the second going from no height to its own, which\n // is how the panel grows to whatever the rows come to.\n results: 'grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none',\n resultsClip: 'min-h-0 overflow-hidden',\n resultsInner: 'flex flex-col gap-0.5 border-t p-1.5',\n row: 'flex items-center gap-3 rounded-xl px-2.5 py-2 text-sm transition-[opacity,translate] duration-300 ease-out starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground',\n status: 'flex items-center gap-2 px-2.5 py-2 text-sm text-muted-foreground',\n dots: 'flex gap-1',\n dot: 'size-1 animate-pulse rounded-full bg-muted-foreground',\n },\n variants: {\n /*\n * Opening and closing are the same steps in opposite orders. Opening, the\n * surface widens and the rows follow it down. Closing, the rows fold up\n * first and the surface narrows after them, so what is seen is one thing\n * putting itself away and not two things changing at once.\n */\n open: {\n true: {\n // One length, not a width and a limit on it: a change of width is\n // animated from the width as written, and `100%` with a `max-width`\n // would start the closing from the full width of whatever it is in.\n surface: 'w-[min(100%,28rem)] rounded-3xl shadow-lg',\n trigger: 'pointer-events-none opacity-0',\n form: 'opacity-100 delay-150',\n },\n false: {\n surface: 'w-[var(--pu-closed)] rounded-[1.375rem] delay-200',\n trigger: 'opacity-100 delay-300',\n form: 'pointer-events-none opacity-0',\n },\n },\n filled: {\n true: { results: 'grid-rows-[1fr] delay-200' },\n false: { results: 'grid-rows-[0fr]' },\n },\n interactive: {\n true: { row: 'cursor-pointer hover:bg-accent focus-visible:bg-accent focus-visible:outline-none' },\n },\n },\n});\n\ninterface CommandDockContextValue {\n open: boolean;\n setOpen(open: boolean): void;\n value: string;\n setValue(value: string): void;\n submit(): void;\n busy: boolean;\n panelId: string;\n}\n\nconst CommandDockContext = createContext(null);\n\nfunction useDock(part: string): CommandDockContextValue {\n const dock = useContext(CommandDockContext);\n if (!dock) throw new Error(`${part} must be used inside a .`);\n return dock;\n}\n\nexport interface CommandDockProps extends Omit, 'onSubmit' | 'defaultValue'> {\n /** Whether it is open, when the caller holds that. */\n open?: boolean;\n /** Whether it starts open, when the caller does not. */\n defaultOpen?: boolean;\n /** Called when it opens or closes. */\n onOpenChange?: (open: boolean) => void;\n /** What is typed in the field, when the caller holds it. */\n value?: string;\n /** Called with what is typed, each time it changes. */\n onValueChange?: (value: string) => void;\n /** Called with what was typed when it is sent. */\n onSubmit?: (value: string) => void;\n /** While true, the field cannot be sent again and `CommandDock.Status` is shown. */\n busy?: boolean;\n /** How wide it is while closed, as a CSS length. */\n closedWidth?: string;\n}\n\nfunction CommandDockRoot({\n open: controlledOpen,\n defaultOpen = false,\n onOpenChange,\n value: controlledValue,\n onValueChange,\n onSubmit,\n busy = false,\n closedWidth = '9rem',\n className,\n style,\n children,\n ...props\n}: CommandDockProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const [internalValue, setInternalValue] = useState('');\n const open = controlledOpen ?? internalOpen;\n const value = controlledValue ?? internalValue;\n const root = useRef(null);\n const panelId = useId();\n const slots = commandDockVariants({ open });\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (controlledOpen === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [controlledOpen, onOpenChange]\n );\n\n const setValue = useCallback(\n (next: string) => {\n if (controlledValue === undefined) setInternalValue(next);\n onValueChange?.(next);\n },\n [controlledValue, onValueChange]\n );\n\n const submit = useCallback(() => {\n const text = value.trim();\n if (text && !busy) onSubmit?.(text);\n }, [value, busy, onSubmit]);\n\n // A press outside closes it. Escape does too, and puts focus back on the\n // surface so the next key goes somewhere sensible.\n useEffect(() => {\n if (!open) return;\n const onPointerDown = (event: PointerEvent) => {\n if (!root.current?.contains(event.target as Node)) setOpen(false);\n };\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || !root.current?.contains(document.activeElement)) return;\n setOpen(false);\n requestAnimationFrame(() => root.current?.querySelector('[data-dock-trigger]')?.focus());\n };\n document.addEventListener('pointerdown', onPointerDown);\n document.addEventListener('keydown', onKeyDown);\n return () => {\n document.removeEventListener('pointerdown', onPointerDown);\n document.removeEventListener('keydown', onKeyDown);\n };\n }, [open, setOpen]);\n\n const context = useMemo(\n () => ({ open, setOpen, value, setValue, submit, busy, panelId }),\n [open, setOpen, value, setValue, submit, busy, panelId]\n );\n\n return (\n \n \n \n );\n}\nCommandDockRoot.displayName = 'CommandDock';\n\n/** Keeps an element out of the tab order and the accessibility tree while it cannot be seen. */\nfunction useInert(inert: boolean) {\n const ref = useRef(null);\n // Before anything else runs: the field asks for focus as the dock opens,\n // and an element inside something still inert cannot take it.\n useLayoutEffect(() => {\n // Set on the element, not passed as a prop: how a boolean `inert` prop is\n // written to the page differs between React versions.\n ref.current?.toggleAttribute('inert', inert);\n }, [inert]);\n return ref;\n}\n\nexport interface CommandDockTriggerProps extends ComponentProps<'button'> {}\n\n/** The closed form: a button that opens the dock. */\nfunction CommandDockTrigger({ className, onClick, ...props }: CommandDockTriggerProps) {\n const dock = useDock('CommandDock.Trigger');\n const ref = useInert(dock.open);\n return (\n {\n onClick?.(event);\n if (!event.defaultPrevented) dock.setOpen(true);\n }}\n {...props}\n />\n );\n}\nCommandDockTrigger.displayName = 'CommandDock.Trigger';\n\nexport interface CommandDockPanelProps extends ComponentProps<'div'> {}\n\n/** The open form: the field, and the rows under it. */\nfunction CommandDockPanel({ className, ...props }: CommandDockPanelProps) {\n const dock = useDock('CommandDock.Panel');\n // Kept in the page while closed, so it can be seen folding away. Inert, so\n // nothing in it can be reached while it cannot be seen.\n const ref = useInert(!dock.open);\n return (\n
\n );\n}\nCommandDockPanel.displayName = 'CommandDock.Panel';\n\nexport interface CommandDockInputProps\n extends Omit, 'value' | 'onChange' | 'type'> {\n /** What a screen reader calls the send button. */\n submitLabel?: string;\n}\n\n/** The field, with a button to send what is in it. Enter sends too. */\nfunction CommandDockInput({\n className,\n submitLabel = 'Send',\n 'aria-label': ariaLabel = 'Ask',\n ...props\n}: CommandDockInputProps) {\n const dock = useDock('CommandDock.Input');\n const slots = commandDockVariants({ open: dock.open });\n const field = useRef(null);\n\n // It is opened in order to type in it. Asked for without scrolling: the\n // surface is still on its way to its size, and the page should not chase it.\n useEffect(() => {\n if (dock.open) field.current?.focus({ preventScroll: true });\n }, [dock.open]);\n\n const send = (event: FormEvent) => {\n event.preventDefault();\n dock.submit();\n };\n\n return (\n \n );\n}\nCommandDockInput.displayName = 'CommandDock.Input';\n\nexport interface CommandDockResultsProps extends ComponentProps<'div'> {\n /** What a screen reader calls the list of rows. */\n label?: string;\n}\n\n/** Where the rows go. It takes no room until there is something in it. */\nfunction CommandDockResults({ className, label = 'Results', children, ...props }: CommandDockResultsProps) {\n const dock = useDock('CommandDock.Results');\n const rows = Array.isArray(children) ? children.flat().filter(Boolean).length : children ? 1 : 0;\n // Closed, it folds away whatever it holds.\n const filled = dock.open && (rows > 0 || dock.busy);\n const slots = commandDockVariants({ filled });\n return (\n \n
\n {/* Polite: rows are read as they arrive, without cutting in on typing. */}\n
\n {children}\n
\n
\n
\n );\n}\nCommandDockResults.displayName = 'CommandDock.Results';\n\nexport interface CommandDockRowProps extends ComponentProps<'div'> {\n /** Called when the row is pressed. Passing this makes the row a button. */\n onSelect?: () => void;\n}\n\n/** One row of the answer. It slides in when it is added. */\nfunction CommandDockRow({ className, onSelect, children, ...props }: CommandDockRowProps) {\n const slots = commandDockVariants({ interactive: onSelect !== undefined });\n return (\n \n {onSelect ? (\n
\n {children}\n \n ) : (\n
{children}
\n )}\n
\n );\n}\nCommandDockRow.displayName = 'CommandDock.Row';\n\nexport interface CommandDockStatusProps extends ComponentProps<'div'> {\n children?: ReactNode;\n}\n\n/** Shown under the rows while `busy`: three dots, and words if you give it some. */\nfunction CommandDockStatus({ className, children = 'Working', ...props }: CommandDockStatusProps) {\n const slots = commandDockVariants();\n return (\n \n \n \n \n \n \n {children}\n
\n );\n}\nCommandDockStatus.displayName = 'CommandDock.Status';\n\nexport const CommandDock = Object.assign(CommandDockRoot, {\n Trigger: CommandDockTrigger,\n Panel: CommandDockPanel,\n Input: CommandDockInput,\n Results: CommandDockResults,\n Row: CommandDockRow,\n Status: CommandDockStatus,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n module. */\nexport {\n CommandDockTrigger,\n CommandDockPanel,\n CommandDockInput,\n CommandDockResults,\n CommandDockRow,\n CommandDockStatus,\n};\n"
+ }
+ ]
+}
diff --git a/apps/docs/public/r/web/day-grid.json b/apps/docs/public/r/web/day-grid.json
new file mode 100644
index 00000000..be23db26
--- /dev/null
+++ b/apps/docs/public/r/web/day-grid.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "day-grid",
+ "type": "registry:component",
+ "title": "Day Grid",
+ "description": "A run of days as a field of dots, each sized by how much happened on it.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/day-grid.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/day-grid.tsx",
+ "content": "'use client';\n\n/**\n * DayGrid — a run of days as a field of dots, each sized by how much happened\n * on it.\n *\n * ```tsx\n * \n * \n * ```\n *\n * ## Size carries the amount, not only shade\n *\n * A day is level 0 to 4, and its dot grows with the level: a speck for a day\n * with nothing on it, a full disc for the busiest. The period reads the way a\n * halftone print does, heavy where a lot happened and thin where little did,\n * and it still reads for someone who cannot tell two shades of one colour\n * apart.\n *\n * ## The pointer is a lens\n *\n * Dots swell as the pointer comes near and settle as it leaves, so the day\n * under the pointer is the largest thing on the grid and its neighbours lean\n * towards it. The day's `label` is shown in a tag above it. The lens changes\n * nothing about what the grid says; it is there to make one day easy to\n * single out among forty.\n *\n * ## Days you can ask about\n *\n * Pass `onValueChange` and each day becomes a button. The grid then behaves as\n * one control to the keyboard: a single Tab stop, with the arrow keys moving\n * between days, so forty days are not forty presses of Tab.\n */\nimport {\n useEffect,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type CSSProperties,\n type KeyboardEvent,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\n\n/** How much happened on a day: 0 is nothing, 4 is the most. */\nexport type DayLevel = 0 | 1 | 2 | 3 | 4;\n\n/** Which of the theme's roles the dots are drawn in. */\nexport type DayGridColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';\n\nexport interface DayCell {\n level: DayLevel;\n /** What the day is, for the tag and a screen reader: \"14 May, 6 commits\". */\n label?: string;\n}\n\nconst dayGridVariants = tv({\n slots: {\n root: 'relative grid w-full gap-1',\n // The cell is the target and keeps its size. Only the dot inside it grows.\n cell: 'relative flex aspect-square w-full items-center justify-center rounded-full outline-none',\n /*\n * One scale made of three parts: how big the level is, how near the\n * pointer is, and whether the grid has arrived yet. Kept as variables so\n * each can change without a class changing.\n */\n dot: 'block size-full rounded-full [scale:calc(var(--pu-size)*(1+var(--pu-lens,0)*0.5)*var(--pu-in,1))] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none',\n tag: 'pointer-events-none absolute z-20 whitespace-nowrap rounded-lg bg-foreground px-2 py-1 text-xs font-medium text-background shadow-lg',\n legend: 'flex items-center gap-1.5 text-xs text-muted-foreground',\n swatch: 'flex size-4 items-center justify-center',\n },\n variants: {\n /*\n * The colour is written to one variable and every dot reads it. The legend\n * is a sibling of the grid, not a child, so it takes the same variant.\n */\n color: {\n primary: { root: '[--pu-color:var(--primary)]', legend: '[--pu-color:var(--primary)]' },\n info: { root: '[--pu-color:var(--info)]', legend: '[--pu-color:var(--info)]' },\n success: { root: '[--pu-color:var(--success)]', legend: '[--pu-color:var(--success)]' },\n warning: { root: '[--pu-color:var(--warning)]', legend: '[--pu-color:var(--warning)]' },\n destructive: {\n root: '[--pu-color:var(--destructive)]',\n legend: '[--pu-color:var(--destructive)]',\n },\n },\n level: {\n 0: { dot: 'bg-muted-foreground/30 [--pu-size:0.2]' },\n 1: { dot: 'bg-[color-mix(in_oklab,var(--pu-color)_45%,transparent)] [--pu-size:0.42]' },\n 2: { dot: 'bg-[color-mix(in_oklab,var(--pu-color)_65%,transparent)] [--pu-size:0.6]' },\n 3: { dot: 'bg-[color-mix(in_oklab,var(--pu-color)_85%,transparent)] [--pu-size:0.8]' },\n // The busiest days carry a little light of their own.\n 4: {\n dot: 'bg-[var(--pu-color)] shadow-[0_0_14px_color-mix(in_oklab,var(--pu-color)_45%,transparent)] [--pu-size:1]',\n },\n },\n interactive: {\n true: {\n cell: 'cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',\n },\n },\n selected: {\n true: { cell: 'ring-2 ring-foreground ring-offset-2 ring-offset-background' },\n },\n },\n});\n\nconst LEVELS = [0, 1, 2, 3, 4] as const;\n\n/** How far the lens reaches, in cells. */\nconst LENS_REACH = 2.4;\n/** Milliseconds between one diagonal of dots arriving and the next. */\nconst STAGGER = 22;\n\nexport interface DayGridProps\n extends Omit, 'children' | 'onChange' | 'color'> {\n /** The days, in order. They fill the grid row by row. */\n cells: DayCell[];\n /** Cells per row. Seven makes each row a week. */\n columns?: number;\n /**\n * Cells per column. Giving this fills the grid column by column, and\n * `columns` is ignored. Seven makes each column a week, which lays a long\n * period out wide and short.\n */\n rows?: number;\n /** Which of the theme's roles the dots are drawn in. */\n color?: DayGridColor;\n /** The index of the selected day, when the caller holds it. */\n value?: number | null;\n /**\n * Called with the index of a day when it is pressed. Passing this makes the\n * days buttons; without it the grid is a picture.\n */\n onValueChange?: (index: number) => void;\n /** Set to `false` for dots that hold still under the pointer. */\n lens?: boolean;\n /** What a screen reader calls the grid. */\n label?: string;\n}\n\nfunction DayGridRoot({\n cells,\n columns = 7,\n rows: perColumn,\n color = 'primary',\n value = null,\n onValueChange,\n lens = true,\n label = 'Activity',\n className,\n style,\n onPointerMove,\n onPointerLeave,\n ...props\n}: DayGridProps) {\n const slots = dayGridVariants({ color });\n const root = useRef(null);\n const nodes = useRef<(HTMLElement | null)[]>([]);\n const frame = useRef(0);\n const interactive = onValueChange !== undefined;\n // The day whose tag is showing: the one under the pointer, or with focus.\n const [pointed, setPointed] = useState(null);\n // The dots start at nothing and arrive along the diagonals.\n const [arrived, setArrived] = useState<'no' | 'arriving' | 'yes'>('no');\n\n // Which way the days run: along each row, or down each column.\n const byColumn = perColumn !== undefined;\n const across = byColumn ? Math.ceil(cells.length / Math.max(1, perColumn)) : columns;\n const rows = byColumn ? Math.max(1, perColumn) : Math.ceil(cells.length / columns);\n\n useEffect(() => {\n const start = requestAnimationFrame(() => setArrived('arriving'));\n // Once the last diagonal is in, the delays come off. Left on, they would\n // hold every dot back from the lens by its own share of the stagger.\n const settle = setTimeout(() => setArrived('yes'), (rows + across) * STAGGER + 450);\n return () => {\n cancelAnimationFrame(start);\n clearTimeout(settle);\n };\n }, [rows, across]);\n\n useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n const summary = useMemo(() => {\n const active = cells.filter((cell) => cell.level > 0).length;\n return `${label}: something on ${active} of ${cells.length} days.`;\n }, [cells, label]);\n\n const moveLens = (event: ReactPointerEvent) => {\n onPointerMove?.(event);\n // A finger covers what a lens would show, and has no hover to end it.\n if (event.pointerType === 'touch') return;\n const still = !lens || window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n const { clientX, clientY } = event;\n cancelAnimationFrame(frame.current);\n frame.current = requestAnimationFrame(() => {\n let nearest: number | null = null;\n let best = Infinity;\n nodes.current.forEach((node, index) => {\n if (!node) return;\n const box = node.getBoundingClientRect();\n const distance = Math.hypot(\n clientX - (box.left + box.width / 2),\n clientY - (box.top + box.height / 2)\n );\n if (!still) {\n const near = Math.max(0, 1 - distance / (box.width * LENS_REACH));\n // Eased, so the swell has a soft edge and a round top.\n node.style.setProperty('--pu-lens', (near * near * (3 - 2 * near)).toFixed(3));\n }\n if (distance < best && distance < box.width * 0.75) {\n best = distance;\n nearest = index;\n }\n });\n setPointed(nearest);\n });\n };\n\n const leave = (event: ReactPointerEvent) => {\n onPointerLeave?.(event);\n cancelAnimationFrame(frame.current);\n for (const node of nodes.current) node?.style.removeProperty('--pu-lens');\n setPointed(null);\n };\n\n const layout = byColumn\n ? {\n gridTemplateRows: `repeat(${rows}, auto)`,\n gridAutoFlow: 'column' as const,\n gridAutoColumns: 'minmax(0, 1fr)',\n ...style,\n }\n : { gridTemplateColumns: `repeat(${across}, minmax(0, 1fr))`, ...style };\n\n /** Where a dot is in its entrance, as the styles that put it there. */\n const dotStyle = (index: number): CSSProperties | undefined => {\n if (arrived === 'yes') return undefined;\n const diagonal = byColumn\n ? Math.floor(index / rows) + (index % rows)\n : Math.floor(index / across) + (index % across);\n return {\n ['--pu-in' as string]: arrived === 'no' ? 0 : 1,\n transitionDelay: `${diagonal * STAGGER}ms`,\n transitionDuration: '420ms',\n };\n };\n\n const tag = (() => {\n if (pointed === null) return null;\n const text = cells[pointed]?.label;\n const node = nodes.current[pointed];\n const box = root.current;\n if (!text || !node || !box) return null;\n const centre = node.offsetLeft + node.offsetWidth / 2;\n // Hung from whichever side keeps it inside the grid.\n const share = centre / box.offsetWidth;\n const shift = share < 0.25 ? '-12%' : share > 0.75 ? '-88%' : '-50%';\n return (\n \n {text}\n \n );\n })();\n\n if (!interactive) {\n return (\n \n {cells.map((cell, index) => {\n const part = dayGridVariants({ level: cell.level });\n return (\n {\n nodes.current[index] = node;\n }}\n className={part.cell()}\n >\n \n \n );\n })}\n {tag}\n
\n );\n }\n\n // One Tab stop for the grid: the selected day, or the first.\n const focusable = value !== null && value >= 0 && value < cells.length ? value : 0;\n\n const onKeyDown = (event: KeyboardEvent, index: number) => {\n const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';\n const moves: Record = {\n // One day along a row, or one whole column when the days run downwards.\n ArrowLeft: (rtl ? 1 : -1) * (byColumn ? rows : 1),\n ArrowRight: (rtl ? -1 : 1) * (byColumn ? rows : 1),\n ArrowUp: byColumn ? -1 : -across,\n ArrowDown: byColumn ? 1 : across,\n };\n let next: number | undefined;\n if (event.key in moves) next = index + (moves[event.key] ?? 0);\n else if (event.key === 'Home') next = 0;\n else if (event.key === 'End') next = cells.length - 1;\n if (next === undefined || next < 0 || next >= cells.length) return;\n event.preventDefault();\n nodes.current[next]?.focus();\n onValueChange(next);\n };\n\n return (\n \n {cells.map((cell, index) => {\n const part = dayGridVariants({\n level: cell.level,\n interactive: true,\n selected: value === index,\n });\n return (\n {\n nodes.current[index] = node;\n }}\n type=\"button\"\n aria-label={cell.label ?? `Day ${index + 1}, level ${cell.level} of 4`}\n aria-pressed={value === index}\n tabIndex={index === focusable ? 0 : -1}\n onClick={() => onValueChange(index)}\n onKeyDown={(event) => onKeyDown(event, index)}\n onFocus={(event) => {\n // Only for focus that came from the keyboard: a click focuses\n // too, and the pointer is already showing that day's tag.\n if (event.currentTarget.matches(':focus-visible')) setPointed(index);\n }}\n onBlur={() => setPointed((current) => (current === index ? null : current))}\n className={part.cell()}\n >\n \n \n );\n })}\n {tag}\n
\n );\n}\nDayGridRoot.displayName = 'DayGrid';\n\nexport interface DayGridLegendProps extends Omit, 'children' | 'color'> {\n /** The same role the grid is drawn in. */\n color?: DayGridColor;\n /** The word at the quiet end of the scale. */\n less?: ReactNode;\n /** The word at the busy end of the scale. */\n more?: ReactNode;\n}\n\n/** The five sizes, quietest to busiest, with a word at each end. */\nfunction DayGridLegend({\n color = 'primary',\n less = 'Less',\n more = 'More',\n className,\n ...props\n}: DayGridLegendProps) {\n const slots = dayGridVariants({ color });\n return (\n \n {less} \n {LEVELS.map((level) => (\n \n \n \n ))}\n {more} \n
\n );\n}\nDayGridLegend.displayName = 'DayGrid.Legend';\n\nexport const DayGrid = Object.assign(DayGridRoot, {\n Legend: DayGridLegend,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n module. */\nexport { DayGridLegend };\n"
+ }
+ ]
+}
diff --git a/apps/docs/public/r/web/feedback.json b/apps/docs/public/r/web/feedback.json
new file mode 100644
index 00000000..3838307d
--- /dev/null
+++ b/apps/docs/public/r/web/feedback.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "feedback",
+ "type": "registry:component",
+ "title": "Feedback",
+ "description": "A dialog with a well to write in and two actions in the band around it.",
+ "dependencies": [
+ "@base-ui/react",
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "files": [
+ {
+ "path": "components/panelui/feedback.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/feedback.tsx",
+ "content": "'use client';\n\n/**\n * Feedback — a dialog whose body is something to write in, and whose actions\n * sit in a band around it.\n *\n * ```tsx\n * \n * Send feedback \n * \n * \n * What should we fix first? \n * \n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## The panel is a well\n *\n * A dialog that asks for a sentence has two jobs on screen at once: hold what\n * is being written, and offer the two things to do with it. Here the writing\n * surface is set into the dialog and the buttons sit in the band around it.\n * The recess says the field is the page and the rest is its frame, before a\n * word has been read. It is also why the field has no border of its own: an\n * outline inside a well is two edges making one point.\n *\n * ## Nothing is sent empty\n *\n * `Submit` is inert while the field is empty. A dialog that accepts an empty\n * answer produces empty feedback, from somebody who believes they said\n * something. Inert, it drops its fill instead of dimming it, because a tinted\n * accent pill still reads as the thing to press whatever its opacity.\n *\n * ## It does not close itself\n *\n * `Submit` hands the message to `onSubmit` and stays open. Sending has to\n * finish first, and a dialog that closed on the press would take its own error\n * message with it.\n */\nimport { Dialog } from '@base-ui/react/dialog';\nimport { ScrollArea } from '@base-ui/react/scroll-area';\nimport {\n createContext,\n useCallback,\n useContext,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\n\nconst feedbackVariants = tv({\n slots: {\n backdrop:\n 'fixed inset-0 z-50 bg-black/40 transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0',\n viewport: 'fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4',\n /*\n * The shell is the popover surface with black mixed into it. The well it\n * holds is the plain popover surface, so the shell is the darker of the\n * two in a light theme and in a dark one, which a step on a grey scale\n * could not promise: those run darker one way and lighter the other.\n *\n * The hairline ring is for a dark theme over a dimmed page, where the\n * shell and what is behind it are both nearly black and the band would\n * otherwise have no edge.\n *\n * 38 and 30 are one corner. The panel's radius is the shell's less the\n * 8px between them, which is what keeps the two curves concentric.\n */\n shell:\n 'relative w-full max-w-sm rounded-[38px] bg-[color-mix(in_oklab,var(--popover),black_5%)] p-2 text-popover-foreground shadow-xl outline-none ring-1 ring-foreground/[0.06] dark:bg-[color-mix(in_oklab,var(--popover),black_14%)]',\n popup:\n 'transition-[opacity,scale,translate] duration-200 ease-[cubic-bezier(0.2,0,0,1)] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:translate-y-2 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',\n panel: 'relative flex flex-col gap-4 rounded-[30px] bg-popover p-5 shadow-xs',\n title: 'pe-9 text-[22px] font-semibold leading-7',\n // Drawn at 22px against the title's cap height; the `after` is the rest\n // of a 44px target, which a circle that size would not fit on the line.\n close:\n 'absolute end-5 top-[23px] inline-flex size-[22px] cursor-pointer items-center justify-center rounded-full bg-foreground/10 text-muted-foreground outline-none transition-colors after:absolute after:-inset-[11px] hover:bg-foreground/15 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',\n /*\n * The field never scrolls itself. It is always as tall as what is written\n * in it, and the area round it does the scrolling, which is what lets the\n * bar be a drawn one: a thin rounded thumb that shows while the pointer is\n * over the field or the text is moving, in place of the browser's own.\n */\n field:\n 'block w-full resize-none overflow-hidden bg-transparent p-0 text-[17px] leading-6 text-foreground outline-none placeholder:text-muted-foreground',\n scroller: 'relative',\n scrollerViewport: 'rounded-[inherit] outline-none data-has-overflow-y:overscroll-y-contain',\n // Moved out into the well's padding, so it runs beside the text and not\n // over the ends of its lines.\n scrollbar:\n 'my-1 -me-3.5 flex w-1.5 opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-hovering:duration-100 data-scrolling:opacity-100 data-scrolling:delay-0 data-scrolling:duration-100',\n thumb: 'relative flex-1 rounded-full bg-foreground/20',\n // Held in from the shell's edge further than the panel is. A row the full\n // width would read as a third edge of the dialog, not as two buttons.\n footer: 'flex items-center gap-3.5 px-[18px] pb-2.5 pt-4',\n action:\n 'inline-flex h-11 flex-1 cursor-pointer items-center justify-center rounded-full text-base outline-none transition-[scale,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.97] motion-reduce:transition-none',\n },\n variants: {\n blur: {\n true: { backdrop: 'bg-black/20 backdrop-blur-md' },\n },\n tone: {\n /** The one that discards. A tint of the foreground, not a fill. */\n cancel: { action: 'bg-foreground/10 font-medium text-muted-foreground hover:bg-foreground/15' },\n /** The one that sends. Filled, and the only filled thing on the dialog. */\n submit: { action: 'bg-primary font-semibold text-primary-foreground hover:bg-primary/90' },\n },\n disabled: {\n true: { action: 'cursor-not-allowed active:scale-100' },\n },\n },\n compoundVariants: [\n // Fainter than Cancel's own tint on purpose. Matched, they would read as\n // two Cancels, not as one live action and one that is not ready.\n {\n tone: 'submit',\n disabled: true,\n class: {\n action:\n 'bg-foreground/[0.04] font-medium text-muted-foreground/50 hover:bg-foreground/[0.04]',\n },\n },\n { tone: 'cancel', disabled: true, class: { action: 'opacity-45' } },\n ],\n});\n\ninterface FeedbackContextValue {\n open: boolean;\n setOpen(open: boolean): void;\n /** What is in the field, so Submit can refuse to send nothing. */\n value: string;\n setValue(value: string): void;\n /** The send Submit would perform, for the field's keyboard shortcut. */\n submit: React.RefObject<(() => void) | null>;\n /** Set by `Feedback.Content`, so the parts inside know which elements to be. */\n inline: boolean;\n}\n\nconst FeedbackContext = createContext(null);\n\nfunction useFeedback(part: string): FeedbackContextValue {\n const context = useContext(FeedbackContext);\n if (!context) throw new Error(`${part} must be used inside a .`);\n return context;\n}\n\nexport interface FeedbackProps {\n children?: ReactNode;\n /** Whether the dialog is open, when the caller holds that. */\n open?: boolean;\n /** Called when the dialog asks to open or close. */\n onOpenChange?: (open: boolean) => void;\n /** Whether it starts open, when the caller does not hold it. */\n defaultOpen?: boolean;\n /** The message, when the caller holds it. Leave unset to let the field keep it. */\n value?: string;\n /** The message to start with, when the caller does not hold it. */\n defaultValue?: string;\n /** Called with the message each time it changes. */\n onValueChange?: (value: string) => void;\n /**\n * Whether a click outside or the Escape key closes the dialog. Turn it off\n * for a message that would be lost by a stray click; Cancel and the close\n * button still work.\n */\n dismissible?: boolean;\n}\n\nfunction FeedbackRoot({\n children,\n open,\n onOpenChange,\n defaultOpen = false,\n value,\n defaultValue = '',\n onValueChange,\n dismissible = true,\n}: FeedbackProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const resolvedOpen = open ?? internalOpen;\n const [internalValue, setInternalValue] = useState(defaultValue);\n const resolvedValue = value ?? internalValue;\n const submit = useRef<(() => void) | null>(null);\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [open, onOpenChange]\n );\n\n const setValue = useCallback(\n (next: string) => {\n if (value === undefined) setInternalValue(next);\n onValueChange?.(next);\n },\n [value, onValueChange]\n );\n\n const context = useMemo(\n () => ({ open: resolvedOpen, setOpen, value: resolvedValue, setValue, submit, inline: false }),\n [resolvedOpen, setOpen, resolvedValue, setValue]\n );\n\n return (\n \n {\n // The primitive has a switch for a press outside and none for\n // Escape, so that one is refused here.\n if (!next && !dismissible && details.reason === 'escape-key') return;\n setOpen(next);\n }}\n >\n {children}\n \n \n );\n}\nFeedbackRoot.displayName = 'Feedback';\n\nexport interface FeedbackTriggerProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * Your own element to be the trigger, such as a styled button. It is given\n * the click handler and the attributes that tie it to the dialog.\n */\n render?: ReactElement;\n}\n\n/** What opens the dialog. A plain button unless `render` supplies one. */\nfunction FeedbackTrigger({ render, children, ...props }: FeedbackTriggerProps) {\n return (\n \n {children}\n \n );\n}\nFeedbackTrigger.displayName = 'Feedback.Trigger';\n\nexport interface FeedbackContentProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * Draw it in the page instead of over it: no backdrop, no trigger, always\n * there. For a feedback box that is part of the page, such as the end of an\n * article. `open` is ignored, and Cancel and the close button only report\n * through `onOpenChange`.\n */\n inline?: boolean;\n /** Frost the page behind the dialog instead of only dimming it. */\n blur?: boolean;\n}\n\n/** The shell: the darker band, and everything set in it. */\nfunction FeedbackContent({\n inline = false,\n blur = false,\n className,\n children,\n ...props\n}: FeedbackContentProps) {\n const feedback = useFeedback('Feedback.Content');\n const slots = feedbackVariants({ blur });\n const context = useMemo(() => ({ ...feedback, inline }), [feedback, inline]);\n\n if (inline) {\n return (\n \n \n {children}\n
\n \n );\n }\n\n return (\n \n \n \n \n {/* The popup is portalled out from under the provider, so the\n context is given again for the parts inside it. */}\n {children} \n \n \n \n );\n}\nFeedbackContent.displayName = 'Feedback.Content';\n\nexport interface FeedbackPanelProps extends ComponentProps<'div'> {}\n\n/** The well set into the shell: the title, the close button and the field. */\nfunction FeedbackPanel({ className, ...props }: FeedbackPanelProps) {\n const slots = feedbackVariants();\n return
;\n}\nFeedbackPanel.displayName = 'Feedback.Panel';\n\nexport interface FeedbackTitleProps extends ComponentProps<'h2'> {}\n\n/** The question. In a dialog it is also what the dialog is announced as. */\nfunction FeedbackTitle({ className, ...props }: FeedbackTitleProps) {\n const { inline } = useFeedback('Feedback.Title');\n const slots = feedbackVariants();\n if (inline) return ;\n return ;\n}\nFeedbackTitle.displayName = 'Feedback.Title';\n\nexport interface FeedbackCloseProps extends Omit, 'children'> {\n /** How the button announces itself to a screen reader. */\n label?: string;\n}\n\n/** The ✕ in the panel's corner. */\nfunction FeedbackClose({ label = 'Close', className, onClick, ...props }: FeedbackCloseProps) {\n const { setOpen } = useFeedback('Feedback.Close');\n const slots = feedbackVariants();\n return (\n {\n onClick?.(event);\n if (!event.defaultPrevented) setOpen(false);\n }}\n {...props}\n >\n \n \n \n \n );\n}\nFeedbackClose.displayName = 'Feedback.Close';\n\nexport interface FeedbackFieldProps\n extends Omit, 'value' | 'defaultValue' | 'onChange'> {\n /** The message. Leave unset to let the dialog hold it. */\n value?: string;\n /** Called with the message each time it changes. */\n onValueChange?: (value: string) => void;\n /** Room to write in, in pixels, before the field starts to grow. */\n minHeight?: number;\n /** How tall the field may grow, in pixels, before the area round it scrolls. */\n maxHeight?: number;\n}\n\n/**\n * What is being written. No border and no background of its own: it is\n * already inside a well.\n */\nfunction FeedbackField({\n value,\n onValueChange,\n minHeight = 200,\n maxHeight = 360,\n className,\n style,\n onKeyDown,\n ref,\n 'aria-label': ariaLabel = 'Your message',\n ...props\n}: FeedbackFieldProps) {\n const feedback = useFeedback('Feedback.Field');\n const slots = feedbackVariants();\n const field = useRef(null);\n const text = value ?? feedback.value;\n\n // As tall as what is written, and never shorter than the room it starts\n // with. Measured with the height let go first, or a field that has grown\n // could never shrink.\n useLayoutEffect(() => {\n const node = field.current;\n if (!node) return;\n node.style.height = 'auto';\n node.style.height = `${Math.max(minHeight, node.scrollHeight)}px`;\n }, [text, minHeight]);\n\n const handleKeyDown = (event: KeyboardEvent) => {\n onKeyDown?.(event);\n if (event.defaultPrevented) return;\n // Enter makes a new line, as it does in any text box. Command or Control\n // with it sends, which is the shortcut for it everywhere else.\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n feedback.submit.current?.();\n }\n };\n\n return (\n \n \n \n \n \n \n \n \n \n );\n}\nFeedbackField.displayName = 'Feedback.Field';\n\nexport interface FeedbackFooterProps extends ComponentProps<'div'> {}\n\n/** The action row, in the band under the panel. */\nfunction FeedbackFooter({ className, ...props }: FeedbackFooterProps) {\n const slots = feedbackVariants();\n return
;\n}\nFeedbackFooter.displayName = 'Feedback.Footer';\n\nexport interface FeedbackActionProps extends ComponentProps<'button'> {}\n\n/** Discards and closes. Call `preventDefault` in `onClick` to keep it open. */\nfunction FeedbackCancel({\n children = 'Cancel',\n className,\n disabled,\n onClick,\n ...props\n}: FeedbackActionProps) {\n const { setOpen } = useFeedback('Feedback.Cancel');\n const slots = feedbackVariants({ tone: 'cancel', disabled: !!disabled });\n return (\n {\n onClick?.(event);\n if (!event.defaultPrevented) setOpen(false);\n }}\n {...props}\n >\n {children}\n \n );\n}\nFeedbackCancel.displayName = 'Feedback.Cancel';\n\nexport interface FeedbackSubmitProps extends Omit {\n /**\n * Called with the message. The dialog stays open: close it yourself once\n * the message has been sent, so a failure still has somewhere to be shown.\n */\n onSubmit?: (value: string) => void;\n}\n\n/** Sends. Inert while the field is empty, unless `disabled` says otherwise. */\nfunction FeedbackSubmit({\n children = 'Submit',\n className,\n disabled,\n onClick,\n onSubmit,\n ...props\n}: FeedbackSubmitProps) {\n const feedback = useFeedback('Feedback.Submit');\n // Empty feedback is worse than none: it is sent by somebody who believes\n // they said something.\n const inert = disabled ?? feedback.value.trim().length === 0;\n const slots = feedbackVariants({ tone: 'submit', disabled: inert });\n\n // What the field's shortcut calls. Kept current on every render, so it\n // sends what is in the field now and not what was there when it mounted.\n feedback.submit.current = inert ? null : () => onSubmit?.(feedback.value);\n\n return (\n {\n onClick?.(event);\n if (!event.defaultPrevented) onSubmit?.(feedback.value);\n }}\n {...props}\n >\n {children}\n \n );\n}\nFeedbackSubmit.displayName = 'Feedback.Submit';\n\nexport const Feedback = Object.assign(FeedbackRoot, {\n Trigger: FeedbackTrigger,\n Content: FeedbackContent,\n Panel: FeedbackPanel,\n Title: FeedbackTitle,\n Close: FeedbackClose,\n Field: FeedbackField,\n Footer: FeedbackFooter,\n Cancel: FeedbackCancel,\n Submit: FeedbackSubmit,\n});\n\n/* The parts by name, for a Server Component, which cannot dot into a client\n module. `` there is `` everywhere else. */\nexport {\n FeedbackTrigger,\n FeedbackContent,\n FeedbackPanel,\n FeedbackTitle,\n FeedbackClose,\n FeedbackField,\n FeedbackFooter,\n FeedbackCancel,\n FeedbackSubmit,\n};\n"
+ }
+ ]
+}
diff --git a/apps/docs/public/r/web/flow.json b/apps/docs/public/r/web/flow.json
new file mode 100644
index 00000000..9d60fe3f
--- /dev/null
+++ b/apps/docs/public/r/web/flow.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "flow",
+ "type": "registry:component",
+ "title": "Flow",
+ "description": "A canvas of nodes joined by edges, that you pan, zoom and rearrange.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/flow/index.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/flow/index.tsx",
+ "content": "'use client';\n\n/**\n * Flow — a canvas of nodes joined by edges, that you pan, zoom and rearrange.\n *\n * ```tsx\n * \n * \n * … \n * … \n * \n * \n * \n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's Flow, so a graph written\n * for one reads the same in the other. What differs is the hand: here the\n * pane is dragged with a mouse, zoomed with a pinch or with Control and the\n * wheel, and every node can be reached and moved from the keyboard.\n *\n * ## Where the positions live\n *\n * A node's `position` is where it starts. From then on the canvas holds it:\n * a drag moves it there, and `onNodeDragEnd` says where it came to rest. Pass\n * a new `position` and the node goes to it, which is how nodes are driven\n * from outside.\n *\n * Edges are drawn from the boxes of the nodes they join, measured off the\n * page. An edge therefore follows a node whatever is inside it, and redraws\n * as either end is dragged.\n *\n * ## Three gestures, one canvas\n *\n * The pane pans, a node drags, and a handle draws a new connection. Each is\n * taken by the innermost thing under the pointer, which is what a hand\n * expects.\n */\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from 'react';\nimport { createPortal } from 'react-dom';\nimport { tv } from 'tailwind-variants';\nimport {\n anchorOf,\n arrivalDirection,\n arrowHeadPath,\n autoSides,\n bezierPath,\n edgePath,\n type FlowPoint,\n type FlowRect,\n type FlowSide,\n} from './flow-paths';\n\nexport type FlowEdgeVariant = 'bezier' | 'smoothstep' | 'step' | 'straight';\n\n/** Which of the theme's roles an edge is drawn in. */\nexport type FlowColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';\n\n/** Where the canvas is looking: how far it is panned, and how far zoomed. */\nexport interface FlowViewport {\n x: number;\n y: number;\n zoom: number;\n}\n\nexport interface FlowNodePosition {\n x: number;\n y: number;\n}\n\n/** One end of an edge: a node, and optionally one of its handles by `id`. */\nexport interface FlowEndpoint {\n node: string;\n handle?: string;\n}\n\nexport type FlowEndpointReference = string | FlowEndpoint;\n\n/** What `onConnect` is handed when a connection lands. */\nexport interface FlowConnection {\n /** Node the drag started from. */\n source: string;\n /** Handle it started from, when that handle has an `id`. */\n sourceHandle?: string;\n /** Node it was dropped on. */\n target: string;\n /** Handle it was dropped on, when it landed on one with an `id`. */\n targetHandle?: string;\n}\n\nconst flowVariants = tv({\n slots: {\n root: 'relative h-96 w-full cursor-grab touch-none select-none overflow-hidden rounded-2xl border bg-background text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing',\n underlay: 'pointer-events-none absolute inset-0 text-muted-foreground/35',\n layer: 'absolute left-0 top-0 origin-top-left will-change-transform',\n overlay: 'pointer-events-none absolute inset-0 *:pointer-events-auto',\n node: 'absolute left-0 top-0 z-10 cursor-grab rounded-2xl border bg-card text-card-foreground shadow-sm outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing',\n group:\n 'absolute left-0 top-0 cursor-grab rounded-3xl border border-dashed bg-muted/40 p-3 pt-8 outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing',\n groupLabel:\n 'absolute start-4 top-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground',\n handle:\n 'absolute z-20 size-3 -translate-x-1/2 -translate-y-1/2 cursor-crosshair rounded-full border-2 border-background bg-muted-foreground outline-none transition-[scale,background-color] hover:scale-150 hover:bg-foreground focus-visible:ring-2 focus-visible:ring-ring',\n edge: 'pointer-events-none absolute left-0 top-0 size-px overflow-visible text-[var(--pu-color)]',\n controls:\n 'absolute flex flex-col divide-y divide-border overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm',\n control:\n 'flex size-9 cursor-pointer items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground active:bg-muted [&_svg]:size-4',\n minimap: 'absolute overflow-hidden rounded-xl border bg-card/90 shadow-sm backdrop-blur-sm',\n },\n variants: {\n color: {\n primary: { edge: '[--pu-color:var(--primary)]' },\n info: { edge: '[--pu-color:var(--info)]' },\n success: { edge: '[--pu-color:var(--success)]' },\n warning: { edge: '[--pu-color:var(--warning)]' },\n destructive: { edge: '[--pu-color:var(--destructive)]' },\n // The colour of a hairline that is meant to be seen but not looked at.\n muted: { edge: '[--pu-color:var(--muted-foreground)]' },\n },\n selected: { true: { node: 'z-20 ring-2 ring-ring' } },\n position: {\n 'top-left': { controls: 'left-3 top-3', minimap: 'left-3 top-3' },\n 'top-right': { controls: 'right-3 top-3', minimap: 'right-3 top-3' },\n 'bottom-left': { controls: 'bottom-3 left-3', minimap: 'bottom-3 left-3' },\n 'bottom-right': { controls: 'bottom-3 right-3', minimap: 'bottom-3 right-3' },\n },\n },\n});\n\ninterface HandleEntry {\n id?: string;\n side: FlowSide;\n offset: number;\n}\n\ninterface FlowContextValue {\n viewport: FlowViewport;\n rects: Record;\n handles: Record;\n locked: boolean;\n size: { width: number; height: number };\n underlay: HTMLElement | null;\n overlay: HTMLElement | null;\n place(id: string, position: FlowNodePosition): void;\n measure(id: string, width: number, height: number): void;\n forget(id: string): void;\n setHandle(node: string, key: string, entry: HandleEntry | null): void;\n zoomBy(factor: number): void;\n fit(): void;\n setLocked(locked: boolean): void;\n connectFrom(source: string, handle: HandleEntry, event: ReactPointerEvent): void;\n onNodeDragEnd?: (id: string, position: FlowNodePosition) => void;\n}\n\nconst FlowContext = createContext(null);\nconst NodeContext = createContext(null);\n\nfunction useFlow(part: string): FlowContextValue {\n const flow = useContext(FlowContext);\n if (!flow) throw new Error(`${part} must be used inside a .`);\n return flow;\n}\n\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n\nconst OPPOSITE: Record = {\n top: 'bottom',\n bottom: 'top',\n left: 'right',\n right: 'left',\n};\n\n/* -------------------------------------------------------------------------- */\n/* Root */\n/* -------------------------------------------------------------------------- */\n\nexport interface FlowProps extends Omit, 'children'> {\n /** Where the canvas looks to begin with. */\n defaultViewport?: FlowViewport;\n /** The furthest out it can be zoomed. */\n minZoom?: number;\n /** The furthest in it can be zoomed. */\n maxZoom?: number;\n /** Whether dragging the pane pans it. */\n panOnDrag?: boolean;\n /** Whether a pinch, or Control with the wheel, zooms it. */\n zoomOnPinch?: boolean;\n /** Fits every node into view once they have been measured. */\n fitViewOnMount?: boolean;\n /** The room left round the nodes when fitting them, in pixels. */\n fitViewPadding?: number;\n /** Called as the canvas is panned or zoomed. */\n onViewportChange?: (viewport: FlowViewport) => void;\n /** Called with a node's new position when a drag or a key press has moved it. */\n onNodeDragEnd?: (id: string, position: FlowNodePosition) => void;\n /** Called when a connection drawn from a handle lands on another node. */\n onConnect?: (connection: FlowConnection) => void;\n /** Decides whether a connection may land. Without it, any two different nodes may be joined. */\n isValidConnection?: (connection: FlowConnection) => boolean;\n /** What a screen reader calls the canvas. */\n label?: string;\n children?: ReactNode;\n}\n\nfunction FlowRoot({\n defaultViewport = { x: 0, y: 0, zoom: 1 },\n minZoom = 0.3,\n maxZoom = 2.5,\n panOnDrag = true,\n zoomOnPinch = true,\n fitViewOnMount = false,\n fitViewPadding = 48,\n onViewportChange,\n onNodeDragEnd,\n onConnect,\n isValidConnection,\n label = 'Diagram',\n className,\n children,\n onKeyDown,\n ...props\n}: FlowProps) {\n const slots = flowVariants();\n const root = useRef(null);\n const [viewport, setViewportState] = useState(defaultViewport);\n const [rects, setRects] = useState>({});\n const [handles, setHandles] = useState>({});\n const [locked, setLocked] = useState(false);\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [underlay, setUnderlay] = useState(null);\n const [overlay, setOverlay] = useState(null);\n /** A connection being drawn: where it leaves from and where the pointer is. */\n const [wire, setWire] = useState<{ from: FlowPoint; side: FlowSide; to: FlowPoint } | null>(null);\n\n const live = useRef({ viewport, rects, size, locked });\n live.current = { viewport, rects, size, locked };\n const callbacks = useRef({ onViewportChange, onConnect, isValidConnection });\n callbacks.current = { onViewportChange, onConnect, isValidConnection };\n\n const setViewport = useCallback((next: FlowViewport) => {\n setViewportState(next);\n callbacks.current.onViewportChange?.(next);\n }, []);\n\n /** A point on the page, as a point on the graph. */\n const toGraph = useCallback((clientX: number, clientY: number): FlowPoint => {\n const box = root.current?.getBoundingClientRect();\n const { x, y, zoom } = live.current.viewport;\n return { x: (clientX - (box?.left ?? 0) - x) / zoom, y: (clientY - (box?.top ?? 0) - y) / zoom };\n }, []);\n\n /** Zooms about a point on the canvas, so that point stays where it is. */\n const zoomAbout = useCallback(\n (factor: number, px: number, py: number) => {\n const { x, y, zoom } = live.current.viewport;\n const next = clamp(zoom * factor, minZoom, maxZoom);\n const ratio = next / zoom;\n setViewport({ x: px - (px - x) * ratio, y: py - (py - y) * ratio, zoom: next });\n },\n [minZoom, maxZoom, setViewport]\n );\n\n const zoomBy = useCallback(\n (factor: number) => {\n const { width, height } = live.current.size;\n zoomAbout(factor, width / 2, height / 2);\n },\n [zoomAbout]\n );\n\n const fit = useCallback(() => {\n const boxes = Object.values(live.current.rects).filter((box) => box.width > 0);\n const { width, height } = live.current.size;\n if (boxes.length === 0 || width === 0) return;\n const left = Math.min(...boxes.map((box) => box.x));\n const top = Math.min(...boxes.map((box) => box.y));\n const right = Math.max(...boxes.map((box) => box.x + box.width));\n const bottom = Math.max(...boxes.map((box) => box.y + box.height));\n const zoom = clamp(\n Math.min(\n (width - fitViewPadding * 2) / Math.max(1, right - left),\n (height - fitViewPadding * 2) / Math.max(1, bottom - top),\n 1\n ),\n minZoom,\n maxZoom\n );\n setViewport({\n x: (width - (right - left) * zoom) / 2 - left * zoom,\n y: (height - (bottom - top) * zoom) / 2 - top * zoom,\n zoom,\n });\n }, [fitViewPadding, minZoom, maxZoom, setViewport]);\n\n // The canvas's own size, for fitting and for the minimap.\n useLayoutEffect(() => {\n const node = root.current;\n if (!node) return;\n const observer = new ResizeObserver(() => {\n setSize({ width: node.clientWidth, height: node.clientHeight });\n });\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n\n // Fitted once, when every node has been measured and the canvas has a size.\n const fitted = useRef(false);\n useEffect(() => {\n if (!fitViewOnMount || fitted.current || size.width === 0) return;\n const boxes = Object.values(rects);\n if (boxes.length === 0 || boxes.some((box) => box.width === 0)) return;\n fitted.current = true;\n fit();\n }, [fitViewOnMount, rects, size, fit]);\n\n // Control with the wheel, which is also what a trackpad pinch sends. The\n // plain wheel is left alone: a diagram in a page must not trap its scroll.\n useEffect(() => {\n const node = root.current;\n if (!node || !zoomOnPinch) return;\n const onWheel = (event: WheelEvent) => {\n if (!event.ctrlKey && !event.metaKey) return;\n event.preventDefault();\n if (live.current.locked) return;\n const box = node.getBoundingClientRect();\n zoomAbout(Math.exp(-event.deltaY * 0.01), event.clientX - box.left, event.clientY - box.top);\n };\n node.addEventListener('wheel', onWheel, { passive: false });\n return () => node.removeEventListener('wheel', onWheel);\n }, [zoomOnPinch, zoomAbout]);\n\n /* Panning, and pinching with two fingers. */\n const pointers = useRef(new Map());\n const gesture = useRef<{ x: number; y: number; spread: number } | null>(null);\n\n const onPointerDown = (event: ReactPointerEvent) => {\n if (event.target !== event.currentTarget && !(event.target as HTMLElement).hasAttribute('data-flow-pane')) return;\n if (event.button !== 0 || locked) return;\n event.currentTarget.setPointerCapture(event.pointerId);\n pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });\n gesture.current = null;\n };\n\n const onPointerMove = (event: ReactPointerEvent) => {\n const held = pointers.current.get(event.pointerId);\n if (!held) return;\n const all = [...pointers.current.values()];\n if (all.length === 1 && panOnDrag) {\n const { x, y, zoom } = live.current.viewport;\n setViewport({ x: x + event.clientX - held.x, y: y + event.clientY - held.y, zoom });\n }\n pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });\n if (all.length === 2 && zoomOnPinch) {\n const [a, b] = [...pointers.current.values()];\n if (!a || !b) return;\n const spread = Math.hypot(a.x - b.x, a.y - b.y);\n const box = event.currentTarget.getBoundingClientRect();\n const mid = { x: (a.x + b.x) / 2 - box.left, y: (a.y + b.y) / 2 - box.top };\n if (gesture.current) zoomAbout(spread / gesture.current.spread, mid.x, mid.y);\n gesture.current = { ...mid, spread };\n }\n };\n\n const onPointerEnd = (event: ReactPointerEvent) => {\n pointers.current.delete(event.pointerId);\n gesture.current = null;\n };\n\n /* Drawing a connection from a handle. */\n const connectFrom = useCallback(\n (source: string, handle: HandleEntry, event: ReactPointerEvent) => {\n const box = live.current.rects[source];\n if (!box || live.current.locked) return;\n const from = anchorOf(box, handle.side, handle.offset);\n setWire({ from, side: handle.side, to: toGraph(event.clientX, event.clientY) });\n\n const move = (next: PointerEvent) => {\n setWire({ from, side: handle.side, to: toGraph(next.clientX, next.clientY) });\n };\n const up = (next: PointerEvent) => {\n window.removeEventListener('pointermove', move);\n window.removeEventListener('pointerup', up);\n window.removeEventListener('pointercancel', up);\n setWire(null);\n if (next.type === 'pointercancel') return;\n const under = document.elementFromPoint(next.clientX, next.clientY);\n const onHandle = under?.closest('[data-flow-handle]');\n const onNode = under?.closest('[data-flow-node]');\n const target = onNode?.dataset.flowNode;\n if (!target || target === source || !root.current?.contains(onNode ?? null)) return;\n const connection: FlowConnection = {\n source,\n sourceHandle: handle.id,\n target,\n targetHandle: onHandle?.dataset.flowHandle || undefined,\n };\n if (callbacks.current.isValidConnection?.(connection) === false) return;\n callbacks.current.onConnect?.(connection);\n };\n window.addEventListener('pointermove', move);\n window.addEventListener('pointerup', up);\n window.addEventListener('pointercancel', up);\n },\n [toGraph]\n );\n\n const place = useCallback((id: string, position: FlowNodePosition) => {\n setRects((current) => {\n const before = current[id];\n if (before && before.x === position.x && before.y === position.y) return current;\n return {\n ...current,\n [id]: { x: position.x, y: position.y, width: before?.width ?? 0, height: before?.height ?? 0 },\n };\n });\n }, []);\n\n const measure = useCallback((id: string, width: number, height: number) => {\n setRects((current) => {\n const before = current[id];\n if (!before || (before.width === width && before.height === height)) return current;\n return { ...current, [id]: { ...before, width, height } };\n });\n }, []);\n\n const forget = useCallback((id: string) => {\n setRects((current) => {\n if (!(id in current)) return current;\n const { [id]: _gone, ...rest } = current;\n return rest;\n });\n }, []);\n\n const setHandle = useCallback((node: string, key: string, entry: HandleEntry | null) => {\n setHandles((current) => {\n const others = (current[node] ?? []).filter((handle) => (handle.id ?? handle.side) !== key);\n return { ...current, [node]: entry ? [...others, entry] : others };\n });\n }, []);\n\n const context = useMemo(\n () => ({\n viewport,\n rects,\n handles,\n locked,\n size,\n underlay,\n overlay,\n place,\n measure,\n forget,\n setHandle,\n zoomBy,\n fit,\n setLocked,\n connectFrom,\n onNodeDragEnd,\n }),\n [\n viewport,\n rects,\n handles,\n locked,\n size,\n underlay,\n overlay,\n place,\n measure,\n forget,\n setHandle,\n zoomBy,\n fit,\n connectFrom,\n onNodeDragEnd,\n ]\n );\n\n const keys = (event: KeyboardEvent) => {\n onKeyDown?.(event);\n if (event.defaultPrevented || event.target !== event.currentTarget) return;\n const pan: Record = {\n ArrowLeft: [40, 0],\n ArrowRight: [-40, 0],\n ArrowUp: [0, 40],\n ArrowDown: [0, -40],\n };\n const by = pan[event.key];\n if (by && !locked) {\n event.preventDefault();\n setViewport({ ...viewport, x: viewport.x + by[0], y: viewport.y + by[1] });\n } else if (event.key === '+' || event.key === '=') zoomBy(1.3);\n else if (event.key === '-') zoomBy(1 / 1.3);\n else if (event.key === '0') fit();\n };\n\n return (\n \n \n
\n
\n {children}\n {wire ? (\n
\n \n \n ) : null}\n
\n
\n
\n \n );\n}\nFlowRoot.displayName = 'Flow';\n\n/* -------------------------------------------------------------------------- */\n/* Background */\n/* -------------------------------------------------------------------------- */\n\nexport interface FlowBackgroundProps {\n /** Dots, a grid of lines, small crosses, or nothing. */\n variant?: 'dots' | 'lines' | 'cross' | 'none';\n /** The distance from one mark to the next, in pixels at a zoom of 1. */\n gap?: number;\n /** How big a mark is: a dot's radius, a line's thickness, half a cross's arm. */\n size?: number;\n className?: string;\n}\n\n/** The pattern behind the graph, which moves and scales with it. */\nfunction FlowBackground({ variant = 'dots', gap = 24, size, className }: FlowBackgroundProps) {\n const flow = useFlow('Flow.Background');\n const id = useId();\n if (!flow.underlay || variant === 'none') return null;\n const { x, y, zoom } = flow.viewport;\n const mark = size ?? (variant === 'dots' ? 1.2 : variant === 'lines' ? 1 : 3);\n\n return createPortal(\n \n \n {variant === 'dots' ? : null}\n {variant === 'lines' ? (\n \n ) : null}\n {variant === 'cross' ? (\n \n ) : null}\n \n \n ,\n flow.underlay\n );\n}\nFlowBackground.displayName = 'Flow.Background';\n\n/* -------------------------------------------------------------------------- */\n/* Node and Group */\n/* -------------------------------------------------------------------------- */\n\nexport interface FlowNodeProps extends Omit, 'id'> {\n /** What edges and `onNodeDragEnd` call this node. */\n id: string;\n /** Where its top left corner is, on the graph. */\n position: FlowNodePosition;\n /** Whether it can be dragged and moved with the arrow keys. */\n draggable?: boolean;\n /** Draws it as the one that has been picked. */\n selected?: boolean;\n /** Called when it is pressed without being dragged, or with Enter. */\n onPress?: () => void;\n /** Called when Delete or Backspace is pressed on it. */\n onDelete?: () => void;\n /** How far one arrow key press moves it, in pixels. Shift moves four times as far. */\n moveStep?: number;\n}\n\n/** What a node and a group share: a box on the graph that can be moved. */\nfunction useMovable({\n id,\n position,\n draggable,\n onPress,\n onDelete,\n moveStep,\n}: Pick) {\n const flow = useFlow('Flow.Node');\n const element = useRef(null);\n const { place, measure, forget } = flow;\n const step = moveStep ?? 24;\n\n useLayoutEffect(() => {\n place(id, position);\n // Only when the caller's position changes, not on every drawing.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [id, position.x, position.y, place]);\n\n useLayoutEffect(() => {\n const node = element.current;\n if (!node) return;\n const read = () => measure(id, node.offsetWidth, node.offsetHeight);\n read();\n const observer = new ResizeObserver(read);\n observer.observe(node);\n return () => observer.disconnect();\n }, [id, measure]);\n\n useEffect(() => () => forget(id), [id, forget]);\n\n const at = flow.rects[id] ?? { ...position, width: 0, height: 0 };\n const drag = useRef<{ x: number; y: number; from: FlowNodePosition; moved: boolean } | null>(null);\n const latest = useRef(at);\n latest.current = at;\n\n const onPointerDown = (event: ReactPointerEvent) => {\n if (event.button !== 0) return;\n // A handle starts a connection, and a control inside a node is pressed.\n if ((event.target as HTMLElement).closest('[data-flow-handle], button, a, input, textarea, select, [data-no-drag]')) {\n return;\n }\n // Kept from the pane, and from a group this node may be sitting in.\n event.stopPropagation();\n event.currentTarget.setPointerCapture(event.pointerId);\n drag.current = { x: event.clientX, y: event.clientY, from: { x: at.x, y: at.y }, moved: false };\n };\n\n const onPointerMove = (event: ReactPointerEvent) => {\n const state = drag.current;\n if (!state) return;\n const dx = event.clientX - state.x;\n const dy = event.clientY - state.y;\n if (!state.moved && Math.hypot(dx, dy) < 3) return;\n state.moved = true;\n if (draggable === false || flow.locked) return;\n place(id, { x: state.from.x + dx / flow.viewport.zoom, y: state.from.y + dy / flow.viewport.zoom });\n };\n\n const onPointerUp = () => {\n const state = drag.current;\n drag.current = null;\n if (!state) return;\n if (!state.moved) onPress?.();\n else if (draggable !== false && !flow.locked) {\n flow.onNodeDragEnd?.(id, { x: latest.current.x, y: latest.current.y });\n }\n };\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.target !== event.currentTarget) return;\n const moves: Record = {\n ArrowLeft: [-1, 0],\n ArrowRight: [1, 0],\n ArrowUp: [0, -1],\n ArrowDown: [0, 1],\n };\n const by = moves[event.key];\n if (by) {\n // Kept from the canvas, which would pan on the same key.\n event.stopPropagation();\n if (draggable === false || flow.locked) return;\n event.preventDefault();\n const far = step * (event.shiftKey ? 4 : 1);\n const next = { x: at.x + by[0] * far, y: at.y + by[1] * far };\n place(id, next);\n flow.onNodeDragEnd?.(id, next);\n } else if (event.key === 'Enter' || event.key === ' ') {\n if (!onPress) return;\n event.preventDefault();\n onPress();\n } else if ((event.key === 'Delete' || event.key === 'Backspace') && onDelete) {\n event.preventDefault();\n onDelete();\n }\n };\n\n return {\n element,\n style: { translate: `${at.x.toFixed(2)}px ${at.y.toFixed(2)}px` },\n handlers: {\n onPointerDown,\n onPointerMove,\n onPointerUp,\n onPointerCancel: () => {\n drag.current = null;\n },\n onKeyDown,\n },\n };\n}\n\n/** A box on the graph. Whatever is inside it is yours. */\nfunction FlowNode({\n id,\n position,\n draggable = true,\n selected = false,\n onPress,\n onDelete,\n moveStep,\n className,\n style,\n children,\n ...props\n}: FlowNodeProps) {\n const movable = useMovable({ id, position, draggable, onPress, onDelete, moveStep });\n return (\n \n \n {children}\n
\n \n );\n}\nFlowNode.displayName = 'Flow.Node';\n\nexport interface FlowGroupProps extends FlowNodeProps {\n /** Written in the corner of the group. */\n label?: ReactNode;\n}\n\n/**\n * A dashed box round a few things that belong together. It is a node like any\n * other: it is dragged as one, and an edge can be joined to it.\n */\nfunction FlowGroup({\n id,\n position,\n draggable = true,\n selected: _selected,\n onPress,\n onDelete,\n moveStep,\n label,\n className,\n style,\n children,\n ...props\n}: FlowGroupProps) {\n const movable = useMovable({ id, position, draggable, onPress, onDelete, moveStep });\n return (\n \n \n {label !== undefined ? {label} : null}\n {children}\n
\n \n );\n}\nFlowGroup.displayName = 'Flow.Group';\n\n/* -------------------------------------------------------------------------- */\n/* Handle */\n/* -------------------------------------------------------------------------- */\n\nexport interface FlowHandleProps extends Omit, 'id'> {\n /** A name for this handle, so an edge can be joined to it and not only to the node. */\n id?: string;\n /** Which face of the node it sits on. */\n position?: FlowSide;\n /** How far along that face, from 0 to 1. */\n offset?: number;\n}\n\nconst SIDE_STYLE: Record { left: string; top: string }> = {\n top: (offset) => ({ left: `${offset * 100}%`, top: '0%' }),\n bottom: (offset) => ({ left: `${offset * 100}%`, top: '100%' }),\n left: (offset) => ({ left: '0%', top: `${offset * 100}%` }),\n right: (offset) => ({ left: '100%', top: `${offset * 100}%` }),\n};\n\n/** A point on a node's edge that a connection can be drawn from, or dropped on. */\nfunction FlowHandle({ id, position = 'right', offset = 0.5, className, style, ...props }: FlowHandleProps) {\n const flow = useFlow('Flow.Handle');\n const node = useContext(NodeContext);\n if (node === null) throw new Error('Flow.Handle must be used inside a .');\n const { setHandle, connectFrom } = flow;\n\n useEffect(() => {\n const key = id ?? position;\n setHandle(node, key, { id, side: position, offset });\n return () => setHandle(node, key, null);\n }, [node, id, position, offset, setHandle]);\n\n return (\n {\n if (event.button !== 0) return;\n event.stopPropagation();\n event.preventDefault();\n connectFrom(node, { id, side: position, offset }, event);\n }}\n {...props}\n />\n );\n}\nFlowHandle.displayName = 'Flow.Handle';\n\n/* -------------------------------------------------------------------------- */\n/* Edge */\n/* -------------------------------------------------------------------------- */\n\nexport interface FlowEdgeProps {\n /** Where it leaves from: a node's `id`, or `{ node, handle }`. */\n from: FlowEndpointReference;\n /** Where it arrives. */\n to: FlowEndpointReference;\n /** A curve, a rounded right-angled route, a square one, or a straight line. */\n variant?: FlowEdgeVariant;\n /** Sets the dashes marching towards the target. */\n animated?: boolean;\n /** Draws it dashed. */\n dashed?: boolean;\n /** One of the theme's roles. Without it the edge is a quiet grey. */\n color?: FlowColor;\n /** How thick the line is, in pixels at a zoom of 1. */\n width?: number;\n /** Draws an arrowhead where it arrives. */\n arrow?: boolean;\n /** The face it leaves from. Worked out from where the two nodes are when left out. */\n fromSide?: FlowSide;\n /** The face it arrives at. */\n toSide?: FlowSide;\n /** How round the corners of a `smoothstep` route are. */\n radius?: number;\n /** How far a routed edge runs straight out of a node before it turns. */\n gap?: number;\n /** How far a `bezier` bulges when its target is behind its source. */\n curvature?: number;\n}\n\nconst endpoint = (reference: FlowEndpointReference): FlowEndpoint =>\n typeof reference === 'string' ? { node: reference } : reference;\n\n/** A line from one node to another, which follows them as they move. */\nfunction FlowEdge({\n from,\n to,\n variant = 'bezier',\n animated = false,\n dashed = false,\n color,\n width = 2,\n arrow = false,\n fromSide,\n toSide,\n radius = 8,\n gap = 20,\n curvature = 0.25,\n}: FlowEdgeProps) {\n const flow = useFlow('Flow.Edge');\n // Whether motion is wanted is only known in the browser.\n const [still, setStill] = useState(true);\n useEffect(() => {\n const query = window.matchMedia('(prefers-reduced-motion: reduce)');\n const read = () => setStill(query.matches);\n read();\n query.addEventListener('change', read);\n return () => query.removeEventListener('change', read);\n }, []);\n\n const source = endpoint(from);\n const target = endpoint(to);\n const a = flow.rects[source.node];\n const b = flow.rects[target.node];\n // Nothing to draw until both ends have been measured.\n if (!a || !b || a.width === 0 || b.width === 0) return null;\n\n const named = (node: string, handle?: string) =>\n handle === undefined ? undefined : flow.handles[node]?.find((entry) => entry.id === handle);\n const out = named(source.node, source.handle);\n const into = named(target.node, target.handle);\n const auto = autoSides(a, b);\n const sideA = fromSide ?? out?.side ?? auto.from;\n const sideB = toSide ?? into?.side ?? auto.to;\n const start = anchorOf(a, sideA, out?.offset ?? 0.5);\n const tip = anchorOf(b, sideB, into?.offset ?? 0.5);\n // The line stops short of the tip when there is an arrowhead to finish it.\n const head = arrow ? 9 : 0;\n const direction = arrivalDirection(variant, start, tip, sideB);\n const length = Math.hypot(direction.x, direction.y) || 1;\n const end = { x: tip.x - (direction.x / length) * head * 0.6, y: tip.y - (direction.y / length) * head * 0.6 };\n const d = edgePath(variant, start, sideA, end, sideB, curvature, radius, gap);\n const dash = dashed || animated ? '6 6' : undefined;\n\n return (\n \n \n {animated && !still ? (\n \n ) : null}\n \n {arrow ? (\n \n ) : null}\n \n );\n}\nFlowEdge.displayName = 'Flow.Edge';\n\n/* -------------------------------------------------------------------------- */\n/* Controls and MiniMap */\n/* -------------------------------------------------------------------------- */\n\ntype FlowCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';\n\nexport interface FlowControlsProps extends Omit, 'children'> {\n /** Shows the two zoom buttons. */\n zoom?: boolean;\n /** Shows the button that fits every node into view. */\n fit?: boolean;\n /** Shows the button that locks the canvas against panning, zooming and dragging. */\n lock?: boolean;\n /** How much one press of a zoom button zooms. */\n step?: number;\n /** Which corner of the canvas they sit in. */\n position?: FlowCorner;\n}\n\nconst icon = (path: ReactNode) => (\n \n {path}\n \n);\n\n/** Buttons for what a pinch and a drag do, for anyone without either. */\nfunction FlowControls({\n zoom = true,\n fit = true,\n lock = true,\n step = 1.3,\n position = 'bottom-right',\n className,\n ...props\n}: FlowControlsProps) {\n const flow = useFlow('Flow.Controls');\n const slots = flowVariants({ position });\n if (!flow.overlay) return null;\n\n return createPortal(\n \n {zoom ? (\n <>\n
flow.zoomBy(step)}>\n {icon( )}\n \n
flow.zoomBy(1 / step)}>\n {icon( )}\n \n >\n ) : null}\n {fit ? (\n
\n {icon( )}\n \n ) : null}\n {lock ? (\n
flow.setLocked(!flow.locked)}\n >\n {icon(\n <>\n \n \n >\n )}\n \n ) : null}\n
,\n flow.overlay\n );\n}\nFlowControls.displayName = 'Flow.Controls';\n\nexport interface FlowMiniMapProps extends Omit, 'children'> {\n /** How wide the map is, in pixels. */\n width?: number;\n /** How tall the map is, in pixels. */\n height?: number;\n /** Which corner of the canvas it sits in. */\n position?: FlowCorner;\n}\n\n/** The whole graph, small, with the part in view marked on it. */\nfunction FlowMiniMap({\n width = 120,\n height = 80,\n position = 'bottom-left',\n className,\n style,\n ...props\n}: FlowMiniMapProps) {\n const flow = useFlow('Flow.MiniMap');\n const slots = flowVariants({ position });\n if (!flow.overlay) return null;\n\n const boxes = Object.values(flow.rects).filter((box) => box.width > 0);\n const { x, y, zoom } = flow.viewport;\n // What the canvas is showing, in the graph's own coordinates.\n const view = { x: -x / zoom, y: -y / zoom, width: flow.size.width / zoom, height: flow.size.height / zoom };\n const all = [...boxes, view];\n const left = Math.min(...all.map((box) => box.x));\n const top = Math.min(...all.map((box) => box.y));\n const right = Math.max(...all.map((box) => box.x + box.width));\n const bottom = Math.max(...all.map((box) => box.y + box.height));\n const pad = 20;\n\n return createPortal(\n \n \n {boxes.map((box, index) => (\n \n ))}\n \n \n
,\n flow.overlay\n );\n}\nFlowMiniMap.displayName = 'Flow.MiniMap';\n\nexport const Flow = Object.assign(FlowRoot, {\n Background: FlowBackground,\n Node: FlowNode,\n Handle: FlowHandle,\n Edge: FlowEdge,\n Group: FlowGroup,\n Controls: FlowControls,\n MiniMap: FlowMiniMap,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n module. */\nexport { FlowBackground, FlowNode, FlowHandle, FlowEdge, FlowGroup, FlowControls, FlowMiniMap };\nexport type { FlowSide, FlowRect, FlowPoint };\n"
+ },
+ {
+ "path": "components/panelui/flow/flow-paths.ts",
+ "type": "registry:component",
+ "target": "@components/panelui/flow/flow-paths.ts",
+ "content": "/**\n * Edge geometry for Flow.\n *\n * Everything here is plain arithmetic on plain numbers: where on a node's face\n * an edge attaches, which faces two nodes should use when nobody has said, and\n * the path between two anchors in each of the four routings. It knows nothing\n * about React or the page, which is what lets it be tested by itself.\n *\n * Kept out of `index.tsx` so the component file stays about components. Named\n * `flow-paths` and not `paths`, because a copied component lands beside others\n * and `paths.ts` would be a land grab on a very common name.\n */\n\n/** Which face of a node an edge leaves from or arrives at. */\nexport type FlowSide = 'top' | 'right' | 'bottom' | 'left';\n\n/** A node's box in graph coordinates. */\nexport interface FlowRect {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\nexport interface FlowPoint {\n x: number;\n y: number;\n}\n\n/** The outward unit normal of a face — which way an edge sets off. */\nexport function sideNormal(side: FlowSide): FlowPoint {\n if (side === 'left') return { x: -1, y: 0 };\n if (side === 'right') return { x: 1, y: 0 };\n if (side === 'top') return { x: 0, y: -1 };\n return { x: 0, y: 1 };\n}\n\n/**\n * Where on a face an edge attaches. `offset` runs 0–1 along the face, so two\n * handles on the same side can sit apart without either knowing about the other.\n */\nexport function anchorOf(rect: FlowRect, side: FlowSide, offset: number): FlowPoint {\n if (side === 'top') {\n return { x: rect.x + rect.width * offset, y: rect.y };\n }\n if (side === 'bottom') {\n return { x: rect.x + rect.width * offset, y: rect.y + rect.height };\n }\n if (side === 'left') {\n return { x: rect.x, y: rect.y + rect.height * offset };\n }\n return { x: rect.x + rect.width, y: rect.y + rect.height * offset };\n}\n\n/**\n * Pushes an anchor out along its own face's normal.\n *\n * For an end attached to something that draws its own border: landing exactly\n * on the border puts the line under the stroke, where it reads as stopping\n * short rather than as arriving.\n */\nexport function standOff(point: FlowPoint, side: FlowSide, by: number): FlowPoint {\n if (by === 0) return point;\n const normal = sideNormal(side);\n return { x: point.x + normal.x * by, y: point.y + normal.y * by };\n}\n\n/**\n * The faces two nodes should use when nobody has said. Whichever axis they are\n * further apart on wins, so nodes side by side connect left-to-right and nodes\n * stacked connect top-to-bottom — and the edge changes its mind as they are\n * dragged past each other, which is what makes a hand-arranged graph stay\n * readable without anyone re-specifying anything.\n */\nexport function autoSides(from: FlowRect, to: FlowRect): { from: FlowSide; to: FlowSide } {\n const dx = to.x + to.width / 2 - (from.x + from.width / 2);\n const dy = to.y + to.height / 2 - (from.y + from.height / 2);\n\n if (Math.abs(dx) > Math.abs(dy)) {\n return dx >= 0 ? { from: 'right', to: 'left' } : { from: 'left', to: 'right' };\n }\n return dy >= 0 ? { from: 'bottom', to: 'top' } : { from: 'top', to: 'bottom' };\n}\n\n/** A line, for a graph where the routing is not the point. */\nexport function straightPath(from: FlowPoint, to: FlowPoint): string {\n return `M${from.x},${from.y} L${to.x},${to.y}`;\n}\n\n/**\n * How far a bezier's control point sits from its anchor.\n *\n * With room between the two faces it is half the gap, which gives the familiar\n * S. When they overlap — the target is *behind* the source — the gap is\n * negative and half of it would fold the curve inside out, so the offset grows\n * with the square root of the overlap instead: enough to bulge the curve clear\n * of the node, never enough to run away as the overlap grows.\n */\nfunction controlOffset(distance: number, curvature: number): number {\n if (distance >= 0) return 0.5 * distance;\n return curvature * 25 * Math.sqrt(-distance);\n}\n\nfunction controlPoint(\n point: FlowPoint,\n side: FlowSide,\n other: FlowPoint,\n curvature: number\n): FlowPoint {\n if (side === 'left') {\n return { x: point.x - controlOffset(point.x - other.x, curvature), y: point.y };\n }\n if (side === 'right') {\n return { x: point.x + controlOffset(other.x - point.x, curvature), y: point.y };\n }\n if (side === 'top') {\n return { x: point.x, y: point.y - controlOffset(point.y - other.y, curvature) };\n }\n return { x: point.x, y: point.y + controlOffset(other.y - point.y, curvature) };\n}\n\n/** A cubic curve leaving and arriving perpendicular to each face. */\nexport function bezierPath(\n from: FlowPoint,\n fromSide: FlowSide,\n to: FlowPoint,\n toSide: FlowSide,\n curvature: number\n): string {\n const c1 = controlPoint(from, fromSide, to, curvature);\n const c2 = controlPoint(to, toSide, from, curvature);\n return `M${from.x},${from.y} C${c1.x},${c1.y} ${c2.x},${c2.y} ${to.x},${to.y}`;\n}\n\nfunction distanceBetween(a: FlowPoint, b: FlowPoint): number {\n const dx = b.x - a.x;\n const dy = b.y - a.y;\n return Math.sqrt(dx * dx + dy * dy);\n}\n\n/**\n * One corner of a stepped path, rounded.\n *\n * The radius is capped at half of the shorter of the two segments meeting here,\n * so a corner between two short runs tucks in rather than overshooting into the\n * segment beyond it and drawing a path that doubles back.\n */\nfunction bend(a: FlowPoint, b: FlowPoint, c: FlowPoint, radius: number): string {\n const size = Math.min(distanceBetween(a, b) / 2, distanceBetween(b, c) / 2, radius);\n\n // Three collinear points are not a corner.\n if ((a.x === b.x && b.x === c.x) || (a.y === b.y && b.y === c.y)) {\n return `L${b.x},${b.y}`;\n }\n\n if (a.y === b.y) {\n const xDir = a.x < c.x ? -1 : 1;\n const yDir = a.y < c.y ? 1 : -1;\n return `L${b.x + size * xDir},${b.y}Q${b.x},${b.y} ${b.x},${b.y + size * yDir}`;\n }\n\n const xDir = a.x < c.x ? 1 : -1;\n const yDir = a.y < c.y ? -1 : 1;\n return `L${b.x},${b.y + size * yDir}Q${b.x},${b.y} ${b.x + size * xDir},${b.y}`;\n}\n\n/**\n * An orthogonal route with rounded corners — the one that reads as wiring.\n *\n * Both ends first step `gap` points straight out along their own face, so the\n * edge leaves the node square-on instead of grazing its corner. From there the\n * two are joined by a single dog-leg, split across the axis they are further\n * apart on.\n *\n * `radius` of 0 gives hard corners, which is the stepped variant.\n */\nexport function smoothStepPath(\n from: FlowPoint,\n fromSide: FlowSide,\n to: FlowPoint,\n toSide: FlowSide,\n radius: number,\n gap: number\n): string {\n const fromNormal = sideNormal(fromSide);\n const toNormal = sideNormal(toSide);\n\n let fromGap = {\n x: from.x + fromNormal.x * gap,\n y: from.y + fromNormal.y * gap,\n };\n let toGap = { x: to.x + toNormal.x * gap, y: to.y + toNormal.y * gap };\n\n // The axis the two gap points are further apart on is the one the dog-leg\n // travels along.\n const horizontal = Math.abs(toGap.x - fromGap.x) > Math.abs(toGap.y - fromGap.y);\n const facing =\n (fromNormal.x !== 0 && toNormal.x !== 0 && fromNormal.x !== toNormal.x) ||\n (fromNormal.y !== 0 && toNormal.y !== 0 && fromNormal.y !== toNormal.y);\n\n let middle: FlowPoint[];\n\n if (facing) {\n // Faces pointing at each other: meet in the middle of the run between them.\n const midX = (fromGap.x + toGap.x) / 2;\n const midY = (fromGap.y + toGap.y) / 2;\n middle =\n fromNormal.x !== 0\n ? [\n { x: midX, y: fromGap.y },\n { x: midX, y: toGap.y },\n ]\n : [\n { x: fromGap.x, y: midY },\n { x: toGap.x, y: midY },\n ];\n } else {\n // Faces pointing the same way, or at right angles: one corner is enough.\n // Which of the two possible corners depends on the travelling axis.\n middle = horizontal ? [{ x: toGap.x, y: fromGap.y }] : [{ x: fromGap.x, y: toGap.y }];\n\n // Two handles on the same face, closer together than the gap: the gap\n // points sit between the anchors and the route folds back over itself.\n // Push them out far enough to clear.\n if (fromSide === toSide) {\n const axis = fromNormal.x !== 0 ? 'x' : 'y';\n const apart = Math.abs(from[axis] - to[axis]);\n if (apart <= gap) {\n const push = Math.min(gap - 1, gap - apart);\n if (axis === 'x') {\n fromGap = { x: fromGap.x + fromNormal.x * push, y: fromGap.y };\n toGap = { x: toGap.x + toNormal.x * push, y: toGap.y };\n middle = [{ x: Math.max(fromGap.x, toGap.x), y: fromGap.y }];\n } else {\n fromGap = { x: fromGap.x, y: fromGap.y + fromNormal.y * push };\n toGap = { x: toGap.x, y: toGap.y + toNormal.y * push };\n middle = [{ x: fromGap.x, y: Math.max(fromGap.y, toGap.y) }];\n }\n }\n }\n }\n\n const points: FlowPoint[] = [from, fromGap, ...middle, toGap, to];\n\n // Drop points that repeat the one before them — a duplicate reads as a\n // zero-length segment and makes the bend either side of it collapse.\n const route: FlowPoint[] = [];\n for (let i = 0; i < points.length; i += 1) {\n const point = points[i]!;\n const previous = route[route.length - 1];\n if (previous && previous.x === point.x && previous.y === point.y) continue;\n route.push(point);\n }\n\n if (route.length < 2) return `M${from.x},${from.y}`;\n\n let d = `M${route[0]!.x},${route[0]!.y}`;\n for (let i = 1; i < route.length - 1; i += 1) {\n d +=\n radius > 0\n ? bend(route[i - 1]!, route[i]!, route[i + 1]!, radius)\n : `L${route[i]!.x},${route[i]!.y}`;\n }\n const last = route[route.length - 1]!;\n d += `L${last.x},${last.y}`;\n return d;\n}\n\n/**\n * The `d` string for an edge between two anchors, in whichever shape was asked\n * for. One entry point, so an edge's path is a single call.\n */\nexport function edgePath(\n variant: 'bezier' | 'smoothstep' | 'step' | 'straight',\n from: FlowPoint,\n fromSide: FlowSide,\n to: FlowPoint,\n toSide: FlowSide,\n curvature: number,\n radius: number,\n gap: number\n): string {\n if (variant === 'straight') return straightPath(from, to);\n if (variant === 'bezier') return bezierPath(from, fromSide, to, toSide, curvature);\n return smoothStepPath(from, fromSide, to, toSide, variant === 'step' ? 0 : radius, gap);\n}\n\n/** Length of an edge's bounding diagonal — enough to size a dash cycle by. */\nexport function edgeSpan(from: FlowPoint, to: FlowPoint): number {\n return distanceBetween(from, to);\n}\n\n/**\n * The arrowhead at an edge's target, as its own closed path.\n *\n * SVG has `marker-end` for exactly this, and it is the obvious way to do it.\n * It is not used, because a path carrying a marker reference stops picking up\n * new geometry on re-render — the arrow-bearing edges freeze where they were\n * first drawn while arrow-less ones follow their nodes. Drawing the triangle\n * as an ordinary path costs nine numbers and behaves.\n *\n * `dirX`/`dirY` is the unit direction of travel *into* the target.\n */\nexport function arrowHeadPath(\n tip: FlowPoint,\n dirX: number,\n dirY: number,\n size: number\n): string {\n const length = Math.sqrt(dirX * dirX + dirY * dirY) || 1;\n const ux = dirX / length;\n const uy = dirY / length;\n // The perpendicular, for the two back corners.\n const px = -uy;\n const py = ux;\n\n const baseX = tip.x - ux * size;\n const baseY = tip.y - uy * size;\n const half = size * 0.45;\n\n return (\n `M${tip.x},${tip.y} ` +\n `L${baseX + px * half},${baseY + py * half} ` +\n `L${baseX - px * half},${baseY - py * half} Z`\n );\n}\n\n/**\n * Which way an edge is travelling as it arrives. Every routing but `straight`\n * comes in perpendicular to the target's face, so the face decides it; a\n * straight line comes in along itself.\n */\nexport function arrivalDirection(\n variant: 'bezier' | 'smoothstep' | 'step' | 'straight',\n from: FlowPoint,\n to: FlowPoint,\n toSide: FlowSide\n): { x: number; y: number } {\n if (variant === 'straight') {\n return { x: to.x - from.x, y: to.y - from.y };\n }\n const normal = sideNormal(toSide);\n // The face points outward; the edge arrives against it.\n return { x: -normal.x, y: -normal.y };\n}\n"
+ }
+ ]
+}
diff --git a/apps/docs/public/r/web/kpi.json b/apps/docs/public/r/web/kpi.json
new file mode 100644
index 00000000..f350d3da
--- /dev/null
+++ b/apps/docs/public/r/web/kpi.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "kpi",
+ "type": "registry:component",
+ "title": "KPI",
+ "description": "One number and what it is doing: its change, its shape over time, its progress to a target.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/kpi.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/kpi.tsx",
+ "content": "/**\n * Kpi — one number, and what it is doing.\n *\n * ```tsx\n * \n * \n * Subscribers \n * \n * \n * 18,240 \n * \n * \n * \n * \n * ```\n *\n * ## The number is the message\n *\n * A metric card is not a chart with a caption. The parts are sized and ordered\n * round the number: a title that stays quiet, a value that does not, a trend\n * that says which way and by how much, and a sparkline that is allowed to be\n * small because nobody reads values off it.\n *\n * ## The trend is given a number\n *\n * `Kpi.Trend` takes the change as a number and decides the sign and the colour\n * itself, so a minus sign and a red cannot fall out of step at the call site.\n * Where a rise is the bad news, such as churn, latency or cost, say so with\n * `goodDirection=\"down\"` and the colour follows the meaning and not the sign.\n *\n * ## The same parts as the app\n *\n * The parts and their names are the app library's Kpi, so a metric written for\n * one reads the same in the other. Two things differ because the platform\n * does: the chart takes a plain list of numbers, and colours are named by the\n * theme's roles as everywhere in this collection.\n *\n * ## Not a client module\n *\n * It holds no state and handles no events, so there is no `'use client'` at\n * the top. A Server Component can render it, dotted parts and all, and it\n * adds nothing to the JavaScript a page sends.\n */\nimport type { ComponentProps, ReactNode } from 'react';\nimport { tv } from 'tailwind-variants';\n\n/** Which of the theme's roles a chart or a bar is drawn in. */\nexport type KpiColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';\n\n/** Which direction of movement is the good news for this metric. */\nexport type KpiGoodDirection = 'up' | 'down' | 'none';\n\n/** How a part is coloured once the metric's polarity has been applied. */\nexport type KpiTone = 'good' | 'bad' | 'flat' | 'neutral';\n\nconst kpiVariants = tv({\n slots: {\n root: 'flex w-full flex-col gap-2 text-foreground',\n // A title in a header takes the room between the icon and the actions.\n header: 'flex items-center gap-2 [&>[data-kpi-title]]:flex-1',\n icon: 'flex size-8 shrink-0 items-center justify-center rounded-xl [&_svg]:size-4',\n title: 'min-w-0 truncate text-sm font-medium text-muted-foreground',\n actions: 'ms-auto flex shrink-0 items-center gap-2',\n content: 'flex items-end justify-between gap-3',\n // The stacked title, value and trend, tight enough to read as one thing.\n stat: 'flex min-w-0 flex-1 flex-col items-start gap-1',\n value: 'text-2xl font-bold tabular-nums tracking-tight text-foreground',\n trend: 'inline-flex shrink-0 items-center gap-1 font-medium',\n caption: 'font-normal text-muted-foreground',\n chart: 'block w-full overflow-visible',\n progress: 'flex w-full flex-col gap-2',\n progressHeader: 'flex items-baseline justify-between gap-3 text-sm',\n progressTrack: 'h-2 w-full overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--pu-color)_16%,transparent)]',\n progressFill: 'h-full rounded-full bg-[var(--pu-color)]',\n footer: 'flex items-center gap-2 text-sm text-muted-foreground',\n separator: 'h-px w-full shrink-0 bg-border',\n group: 'flex w-full divide-border',\n },\n variants: {\n surface: {\n true: { root: 'rounded-3xl border bg-card p-5 text-card-foreground' },\n },\n color: {\n primary: { chart: '[--pu-color:var(--primary)]', progress: '[--pu-color:var(--primary)]' },\n info: { chart: '[--pu-color:var(--info)]', progress: '[--pu-color:var(--info)]' },\n success: { chart: '[--pu-color:var(--success)]', progress: '[--pu-color:var(--success)]' },\n warning: { chart: '[--pu-color:var(--warning)]', progress: '[--pu-color:var(--warning)]' },\n destructive: {\n chart: '[--pu-color:var(--destructive)]',\n progress: '[--pu-color:var(--destructive)]',\n },\n },\n /** Which way the number moved, after the metric's own polarity. */\n tone: {\n good: {\n trend: 'text-[var(--success)]',\n icon: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-[var(--success)]',\n },\n bad: {\n trend: 'text-destructive',\n icon: 'bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)] text-destructive',\n },\n flat: { trend: 'text-muted-foreground', icon: 'bg-muted text-muted-foreground' },\n neutral: { trend: 'text-muted-foreground', icon: 'bg-secondary text-muted-foreground' },\n },\n /*\n * `text` is the quieter of the two and the default: a line of colour under\n * the number, which is where the eye already is. `badge` puts a pill round\n * it for a card dense enough that a bare line of colour gets lost.\n */\n trendVariant: {\n text: { trend: 'text-sm' },\n badge: { trend: 'rounded-full px-2 py-0.5 text-xs' },\n },\n /** Where the chart sits relative to the number. */\n layout: {\n below: {},\n // Beside the number: the text takes the width and the chart a fixed\n // box on the end, so charts line up down a column of cards.\n inline: { content: 'items-center gap-4' },\n },\n orientation: {\n // The padding is on the children and not the rule, so the rule reaches\n // the full height or width of the panel.\n horizontal: {\n group:\n 'flex-row items-stretch divide-x *:min-w-0 *:flex-1 *:px-4 *:first:ps-0 *:last:pe-0',\n },\n vertical: { group: 'flex-col divide-y *:py-4 *:first:pt-0 *:last:pb-0' },\n },\n },\n compoundVariants: [\n {\n trendVariant: 'badge',\n tone: 'good',\n class: { trend: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)]' },\n },\n {\n trendVariant: 'badge',\n tone: 'bad',\n class: { trend: 'bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)]' },\n },\n { trendVariant: 'badge', tone: 'flat', class: { trend: 'bg-muted' } },\n { trendVariant: 'badge', tone: 'neutral', class: { trend: 'bg-muted' } },\n ],\n defaultVariants: { tone: 'neutral', trendVariant: 'text', layout: 'below', color: 'primary' },\n});\n\nexport interface KpiProps extends ComponentProps<'div'> {\n /** Draws the metric as a card of its own. Leave it off inside a frame or a group. */\n surface?: boolean;\n}\n\nfunction KpiRoot({ surface = false, className, ...props }: KpiProps) {\n return
;\n}\nKpiRoot.displayName = 'Kpi';\n\nexport interface KpiHeaderProps extends ComponentProps<'div'> {}\n\n/** The row over the number: an icon, the title, and anything at the end. */\nfunction KpiHeader({ className, ...props }: KpiHeaderProps) {\n return
;\n}\nKpiHeader.displayName = 'Kpi.Header';\n\nexport interface KpiIconProps extends ComponentProps<'span'> {\n /** How the tile is coloured: by good or bad news, or left neutral. */\n tone?: KpiTone;\n}\n\n/** A small tile for an icon, at the start of the header. */\nfunction KpiIcon({ tone = 'neutral', className, ...props }: KpiIconProps) {\n return ;\n}\nKpiIcon.displayName = 'Kpi.Icon';\n\nexport interface KpiTitleProps extends ComponentProps<'div'> {}\n\n/** What the number measures. */\nfunction KpiTitle({ className, ...props }: KpiTitleProps) {\n return
;\n}\nKpiTitle.displayName = 'Kpi.Title';\n\nexport interface KpiActionsProps extends ComponentProps<'div'> {}\n\n/** Whatever belongs at the end of the header: a period, a badge, a button. */\nfunction KpiActions({ className, ...props }: KpiActionsProps) {\n return
;\n}\nKpiActions.displayName = 'Kpi.Actions';\n\nexport interface KpiContentProps extends ComponentProps<'div'> {\n /** `inline` puts a chart beside the number in a fixed box; `below` leaves it under everything. */\n layout?: 'below' | 'inline';\n}\n\n/** The row the number is in, with a trend or a chart at its other end. */\nfunction KpiContent({ layout = 'below', className, ...props }: KpiContentProps) {\n return
;\n}\nKpiContent.displayName = 'Kpi.Content';\n\nexport interface KpiStatProps extends ComponentProps<'div'> {}\n\n/** Title, value and trend stacked as one block, for a row with a chart beside it. */\nfunction KpiStat({ className, ...props }: KpiStatProps) {\n return
;\n}\nKpiStat.displayName = 'Kpi.Stat';\n\nexport interface KpiValueProps extends ComponentProps<'div'> {}\n\n/** The number. */\nfunction KpiValue({ className, ...props }: KpiValueProps) {\n return
;\n}\nKpiValue.displayName = 'Kpi.Value';\n\nexport interface KpiTrendProps extends Omit, 'children'> {\n /** The change. Its sign decides the direction. */\n value: number;\n /** Writes the change. Defaults to a signed percentage with one decimal: \"+7.8%\". */\n format?: (value: number) => string;\n /** Which direction is the good news. `down` for churn, latency, cost; `none` for no colour. */\n goodDirection?: KpiGoodDirection;\n /** `text` is a line of colour; `badge` puts a pill and an arrow round it. */\n variant?: 'text' | 'badge';\n /** Quiet words after the change: \"this month\". */\n caption?: string;\n /** A change this small or smaller is drawn as no change. */\n threshold?: number;\n}\n\n/**\n * The change. Colour comes from what the movement means, not from its sign: a\n * fall in churn is good news and is drawn as good news.\n */\nfunction KpiTrend({\n value,\n format,\n goodDirection = 'up',\n variant = 'text',\n caption,\n threshold = 0,\n className,\n ...props\n}: KpiTrendProps) {\n const flat = Math.abs(value) <= threshold;\n const rising = value > 0;\n const tone: KpiTone = flat\n ? 'flat'\n : goodDirection === 'none'\n ? 'neutral'\n : (rising && goodDirection === 'up') || (!rising && goodDirection === 'down')\n ? 'good'\n : 'bad';\n const slots = kpiVariants({ tone, trendVariant: variant });\n\n const label = format\n ? format(value)\n : // A true minus sign and not a hyphen: at this size a hyphen reads as a\n // dash between two words, and the sign is half the meaning.\n `${rising ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(1)}%`;\n const spoken = `${flat ? 'No change' : rising ? 'Up' : 'Down'} ${Math.abs(value).toFixed(1)} percent${caption ? `, ${caption}` : ''}`;\n\n return (\n \n {/* No arrow in `text`: the sign is already in front of the number. */}\n {variant === 'badge' ? (\n \n {flat ? (\n \n ) : rising ? (\n \n ) : (\n \n )}\n \n ) : null}\n \n {label}\n {caption ? {caption} : null}\n \n \n );\n}\nKpiTrend.displayName = 'Kpi.Trend';\n\nexport interface KpiChartProps extends Omit, 'children' | 'color'> {\n /** The values, oldest first. */\n data: number[];\n /** Which of the theme's roles the line is drawn in. */\n color?: KpiColor;\n /** Fills under the line with a wash of the same colour. */\n filled?: boolean;\n /** How tall the chart is, in pixels. */\n height?: number;\n /**\n * A fixed box for the end of a `layout=\"inline\"` row. Charts beside numbers\n * of different lengths are then all one width, and line up down a column.\n */\n inline?: boolean;\n /** How thick the line is, in pixels. */\n strokeWidth?: number;\n}\n\n/** The box an inline chart is given, in pixels. */\nconst INLINE_WIDTH = 128;\nconst INLINE_HEIGHT = 56;\n/** The grid the line is drawn on. It is stretched to whatever size the chart is. */\nconst GRID_WIDTH = 100;\nconst GRID_HEIGHT = 40;\n\nconst round = (value: number) => Number(value.toFixed(2));\n\n/** A smooth line through the points, as a path. */\nfunction linePath(points: [number, number][]): string {\n if (points.length === 0) return '';\n const [first, ...rest] = points;\n if (!first) return '';\n let path = `M${round(first[0])} ${round(first[1])}`;\n rest.forEach((point, index) => {\n const before = points[index] ?? point;\n // Control points a third of the way along, level with each end, which\n // rounds the corners without the line ever overshooting a value.\n const reach = (point[0] - before[0]) / 3;\n path += ` C${round(before[0] + reach)} ${round(before[1])} ${round(point[0] - reach)} ${round(point[1])} ${round(point[0])} ${round(point[1])}`;\n });\n return path;\n}\n\n/** The sparkline: the shape of the number over time, with no axis and no values. */\nfunction KpiChart({\n data,\n color = 'primary',\n filled = false,\n height,\n inline = false,\n strokeWidth = 2,\n className,\n style,\n ...props\n}: KpiChartProps) {\n const slots = kpiVariants({ color });\n const low = Math.min(...data);\n const high = Math.max(...data);\n const span = high - low || 1;\n // A little room top and bottom, so the line's own thickness is not cut off\n // at its highest and lowest values.\n const pad = 3;\n const points: [number, number][] = data.map((value, index) => [\n data.length === 1 ? GRID_WIDTH / 2 : (index / (data.length - 1)) * GRID_WIDTH,\n pad + (1 - (value - low) / span) * (GRID_HEIGHT - pad * 2),\n ]);\n const line = linePath(points);\n\n return (\n \n {filled && line ? (\n \n ) : null}\n \n \n );\n}\nKpiChart.displayName = 'Kpi.Chart';\n\nexport interface KpiProgressProps extends Omit, 'children' | 'color'> {\n /** How far along. */\n value: number;\n /** What `value` is out of. */\n maxValue?: number;\n /** Words at the start of the row over the bar: \"of 5,000\". */\n label?: ReactNode;\n /** Writes the percentage at the end of the row over the bar. */\n showValueLabel?: boolean;\n /** Which of the theme's roles the bar is drawn in. */\n color?: KpiColor;\n}\n\n/** Progress towards a target, under the number. */\nfunction KpiProgress({\n value,\n maxValue = 100,\n label,\n showValueLabel = false,\n color = 'primary',\n className,\n ...props\n}: KpiProgressProps) {\n const slots = kpiVariants({ color });\n const share = Math.min(1, Math.max(0, maxValue === 0 ? 0 : value / maxValue));\n const percent = Math.round(share * 100);\n return (\n \n {label !== undefined || showValueLabel ? (\n
\n {label} \n {showValueLabel ? {percent}% : null}\n
\n ) : null}\n
\n
\n );\n}\nKpiProgress.displayName = 'Kpi.Progress';\n\nexport interface KpiFooterProps extends ComponentProps<'div'> {}\n\n/** A quiet line under everything: when it was measured, how long is left. */\nfunction KpiFooter({ className, ...props }: KpiFooterProps) {\n return
;\n}\nKpiFooter.displayName = 'Kpi.Footer';\n\nexport interface KpiSeparatorProps extends ComponentProps<'div'> {}\n\n/** A rule between two parts of one card. */\nfunction KpiSeparator({ className, ...props }: KpiSeparatorProps) {\n return
;\n}\nKpiSeparator.displayName = 'Kpi.Separator';\n\nexport interface KpiGroupProps extends ComponentProps<'div'> {\n /** Side by side with upright rules, or stacked with level ones. */\n orientation?: 'horizontal' | 'vertical';\n}\n\n/** Several metrics as one panel, with a rule between each and the next. */\nfunction KpiGroup({ orientation = 'horizontal', className, ...props }: KpiGroupProps) {\n return
;\n}\nKpiGroup.displayName = 'Kpi.Group';\n\nexport const Kpi = Object.assign(KpiRoot, {\n Header: KpiHeader,\n Icon: KpiIcon,\n Title: KpiTitle,\n Actions: KpiActions,\n Content: KpiContent,\n Stat: KpiStat,\n Value: KpiValue,\n Trend: KpiTrend,\n Chart: KpiChart,\n Progress: KpiProgress,\n Footer: KpiFooter,\n Separator: KpiSeparator,\n Group: KpiGroup,\n});\n\nexport {\n KpiHeader,\n KpiIcon,\n KpiTitle,\n KpiActions,\n KpiContent,\n KpiStat,\n KpiValue,\n KpiTrend,\n KpiChart,\n KpiProgress,\n KpiFooter,\n KpiSeparator,\n KpiGroup,\n};\n"
+ }
+ ]
+}
diff --git a/apps/docs/public/r/web/lane-board.json b/apps/docs/public/r/web/lane-board.json
new file mode 100644
index 00000000..6456f130
--- /dev/null
+++ b/apps/docs/public/r/web/lane-board.json
@@ -0,0 +1,32 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "lane-board",
+ "type": "registry:component",
+ "title": "Lane Board",
+ "description": "Lanes of cards that can be dragged within a lane and from one lane to another.",
+ "dependencies": [
+ "@base-ui/react",
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/lane-board.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/lane-board.tsx",
+ "content": "'use client';\n\n/**\n * LaneBoard — lanes side by side, each holding cards that can be dragged\n * within a lane and from one lane to another.\n *\n * ```tsx\n * (\n * <>\n * {card.title} \n * {card.code} \n * >\n * )}\n * />\n * ```\n *\n * ## The data is yours\n *\n * The board is given the lanes and their cards and says what they should\n * become. It keeps nothing you did not give it, so the order on screen is the\n * order in your state, and a move can be refused by not applying it.\n *\n * ## A card is carried, and the rest make room\n *\n * A dragged card is lifted off the board and follows the pointer. Where it\n * came from closes up, and a gap opens where it would land if it were let go\n * now, with the cards round it sliding to their new places. Nothing is\n * decided until the pointer is released, and Escape puts everything back.\n *\n * ## The board has a height\n *\n * The board is a fixed height and every lane fills it. A lane with more cards\n * than fit scrolls on its own, so a card added to a long lane moves nothing\n * else on the page. Pass `height=\"auto\"` for a board that grows with its\n * longest lane.\n *\n * ## It works without a pointer\n *\n * Space or Enter on a card picks it up. The arrow keys then move it, up and\n * down within its lane and left and right between lanes. Space or Enter sets\n * it down, Escape puts it back, and each step is said to a screen reader.\n */\nimport { ScrollArea } from '@base-ui/react/scroll-area';\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type ComponentProps,\n type CSSProperties,\n type KeyboardEvent,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\n\n/** Which of the theme's roles a part is drawn in. */\nexport type LaneBoardColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';\n\n/** A card is anything with an id that is its own across the whole board. */\nexport interface LaneBoardCard {\n id: string;\n}\n\nexport interface LaneBoardLane {\n id: string;\n /** Written at the head of the lane. */\n title: ReactNode;\n cards: T[];\n /** One of the theme's roles, drawn as a dot before the title. */\n color?: LaneBoardColor;\n /**\n * How many cards the lane is meant to hold. The count is written against\n * it, and drawn as a warning once the lane holds more. It is advice: the\n * board does not refuse a card. Do that in `onLanesChange`.\n */\n limit?: number;\n}\n\n/** One card going from one place to another. */\nexport interface LaneBoardMove {\n card: T;\n from: { lane: string; index: number };\n to: { lane: string; index: number };\n}\n\nconst laneBoardVariants = tv({\n slots: {\n root: 'relative w-full',\n board: 'size-full',\n // Lanes share the width when there is room and scroll sideways, one lane\n // at a time, when there is not. Room is made under them for the bar only\n // when there is something to scroll.\n boardViewport: 'size-full snap-x outline-none data-has-overflow-x:pb-3',\n lanes: 'flex h-full gap-3',\n /*\n * The border is the lane's own, inside its box, and not a ring round it:\n * the board clips what is drawn outside a lane, and a ring would lose its\n * top edge to that.\n */\n lane: 'flex min-h-0 min-w-50 flex-1 shrink-0 snap-start flex-col rounded-3xl border bg-muted py-2 transition-colors duration-200',\n laneHeader: 'flex items-center justify-between gap-3 px-4.5 pb-1.5 pt-1.5 text-sm',\n laneScroller: 'min-h-0 flex-1',\n /*\n * Padded on every side, so a card's focus ring and shadow have room and\n * are not cut by the edge they scroll under. Where cards carry on past an\n * edge, they fade out towards it.\n */\n laneViewport:\n 'h-full px-2 py-0.5 outline-none data-has-overflow-y:overscroll-y-contain data-has-overflow-y:[mask-image:linear-gradient(to_bottom,transparent,black_min(1.25rem,var(--scroll-area-overflow-y-start)),black_calc(100%-min(1.25rem,var(--scroll-area-overflow-y-end))),transparent)]',\n laneFooter: 'flex flex-col px-2 pt-1.5 empty:hidden',\n // A drawn bar, thin and rounded, that shows while the pointer is on the\n // lane or it is being scrolled.\n scrollbar:\n 'flex opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-hovering:duration-100 data-scrolling:opacity-100 data-scrolling:delay-0 data-scrolling:duration-100 data-[orientation=horizontal]:mx-3 data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-px data-[orientation=vertical]:w-1.5',\n thumb: 'relative flex-1 rounded-full bg-foreground/20',\n laneHeading: 'flex min-w-0 items-center gap-2',\n laneDot: 'size-2 shrink-0 rounded-full bg-[var(--pu-color)]',\n laneTitle: 'truncate font-medium text-muted-foreground',\n laneCount: 'tabular-nums text-muted-foreground',\n // Tall enough when empty to be somewhere a card can be dropped.\n list: 'flex min-h-20 flex-col gap-2',\n card: 'flex cursor-grab touch-manipulation select-none flex-col gap-1 rounded-2xl border bg-card p-4 text-card-foreground shadow-xs outline-none transition-shadow duration-150 focus-visible:ring-2 focus-visible:ring-ring',\n overlay:\n 'pointer-events-none fixed left-0 top-0 z-50 flex rotate-2 cursor-grabbing flex-col gap-1 rounded-2xl border bg-card p-4 text-card-foreground shadow-xl',\n title: 'truncate text-base font-semibold',\n code: 'truncate font-mono text-sm text-muted-foreground',\n row: 'mt-2 flex items-center justify-between gap-3',\n steps: 'flex items-center gap-1',\n step: 'h-1 w-4 rounded-full',\n tag: 'inline-flex items-center rounded-full border border-[color-mix(in_oklab,var(--pu-color)_35%,var(--card))] bg-[color-mix(in_oklab,var(--pu-color)_14%,transparent)] px-2 py-0.5 text-xs font-medium text-[var(--pu-color)]',\n meta: 'font-mono text-sm tabular-nums text-foreground',\n },\n variants: {\n color: {\n primary: {\n steps: '[--pu-color:var(--primary)]',\n tag: '[--pu-color:var(--primary)]',\n laneDot: '[--pu-color:var(--primary)]',\n },\n info: {\n steps: '[--pu-color:var(--info)]',\n tag: '[--pu-color:var(--info)]',\n laneDot: '[--pu-color:var(--info)]',\n },\n success: {\n steps: '[--pu-color:var(--success)]',\n tag: '[--pu-color:var(--success)]',\n laneDot: '[--pu-color:var(--success)]',\n },\n warning: {\n steps: '[--pu-color:var(--warning)]',\n tag: '[--pu-color:var(--warning)]',\n laneDot: '[--pu-color:var(--warning)]',\n },\n destructive: {\n steps: '[--pu-color:var(--destructive)]',\n tag: '[--pu-color:var(--destructive)]',\n laneDot: '[--pu-color:var(--destructive)]',\n },\n },\n // The lane a carried card is over: a firmer edge and a deeper tray.\n over: { true: { lane: 'border-ring/70 bg-foreground/8' } },\n // A lane holding more than its limit says so in its count.\n full: { true: { laneCount: 'font-medium text-[var(--warning)]' } },\n state: {\n rest: {},\n // Where a carried card would land: its room is kept and outlined, and\n // what is on it is not drawn.\n placeholder: { card: 'border-dashed border-ring/70 bg-transparent shadow-none *:invisible' },\n // Picked up with the keyboard: it stays where it is and is ringed.\n held: { card: 'cursor-grabbing shadow-lg ring-2 ring-ring' },\n },\n lit: {\n true: { step: 'bg-[var(--pu-color)]' },\n false: { step: 'bg-muted-foreground/20' },\n },\n },\n defaultVariants: { color: 'primary', state: 'rest' },\n});\n\n/** How far a mouse has to move before a press becomes a drag, in pixels. */\nconst SLOP = 4;\n/** How long a finger has to rest on a card before it is picked up, in milliseconds. */\nconst HOLD = 220;\n/** How near an edge of the board the pointer has to be for it to scroll, in pixels. */\nconst EDGE = 48;\n\n/** Where something is laid out, measured from the board through whatever it is nested in. */\nfunction placeIn(node: HTMLElement, board: HTMLElement) {\n let x = 0;\n let y = 0;\n let at: HTMLElement | null = node;\n while (at && at !== board) {\n x += at.offsetLeft;\n y += at.offsetTop;\n at = at.offsetParent as HTMLElement | null;\n }\n return { x, y };\n}\n\nconst reducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** The lanes with one card taken out and put somewhere else. */\nfunction moved(\n lanes: LaneBoardLane[],\n id: string,\n toLane: string,\n toIndex: number\n): LaneBoardLane[] {\n let card: T | undefined;\n const without = lanes.map((lane) => {\n const found = lane.cards.find((entry) => entry.id === id);\n if (!found) return lane;\n card = found;\n return { ...lane, cards: lane.cards.filter((entry) => entry.id !== id) };\n });\n if (!card) return lanes;\n const carried = card;\n return without.map((lane) => {\n if (lane.id !== toLane) return lane;\n const cards = [...lane.cards];\n cards.splice(Math.min(Math.max(0, toIndex), cards.length), 0, carried);\n return { ...lane, cards };\n });\n}\n\nfunction whereIs(lanes: LaneBoardLane[], id: string) {\n for (const lane of lanes) {\n const index = lane.cards.findIndex((card) => card.id === id);\n if (index >= 0) return { lane: lane.id, index, card: lane.cards[index] as T };\n }\n return null;\n}\n\nexport interface LaneBoardProps\n extends Omit, 'children' | 'onChange'> {\n /** The lanes and their cards, when the caller holds them. */\n lanes?: LaneBoardLane[];\n /** The lanes to begin with, when the caller does not hold them. */\n defaultLanes?: LaneBoardLane[];\n /** Called with the lanes as they should now be, when a card is set down somewhere new. */\n onLanesChange?: (lanes: LaneBoardLane[]) => void;\n /** Called with the one card that moved, and where from and to. */\n onMove?: (move: LaneBoardMove) => void;\n /** Draws the inside of a card. */\n renderCard: (card: T, state: { lane: LaneBoardLane; carried: boolean }) => ReactNode;\n /** Draws something under a lane's cards, such as a button that adds one. */\n renderLaneFooter?: (lane: LaneBoardLane) => ReactNode;\n /** What a screen reader calls a card, where its id would not mean much. */\n cardName?: (card: T) => string;\n /** What a screen reader calls the board. */\n label?: string;\n /**\n * How tall the board is: a number of pixels, or any CSS length. Lanes fill\n * it, and one with more cards than fit scrolls. `'auto'` lets the board\n * grow with its longest lane.\n */\n height?: number | string;\n}\n\nfunction LaneBoardRoot({\n lanes: controlled,\n defaultLanes,\n onLanesChange,\n onMove,\n renderCard,\n renderLaneFooter,\n cardName = (card) => card.id,\n label = 'Board',\n height = '28rem',\n className,\n style,\n ...props\n}: LaneBoardProps) {\n const slots = laneBoardVariants();\n const [internal, setInternal] = useState[]>(defaultLanes ?? []);\n const settled = controlled ?? internal;\n\n /** The lanes as they would be if the carried card were set down now. */\n const [draft, setDraft] = useState[] | null>(null);\n /** The card being carried, and whether by the pointer or the keyboard. */\n const [carry, setCarry] = useState<{ id: string; by: 'pointer' | 'keys' } | null>(null);\n const [said, setSaid] = useState('');\n const shown = draft ?? settled;\n\n const root = useRef(null);\n const overlay = useRef(null);\n const cards = useRef(new Map());\n /** The board's own scroller, which moves sideways. */\n const scroller = useRef(null);\n const sections = useRef(new Map());\n /** Each lane's scroller, which moves up and down. */\n const viewports = useRef(new Map());\n const places = useRef(\n new Map()\n );\n /** Everything about a pointer drag that changes too often to be state. */\n const drag = useRef<{\n id: string;\n pointerId: number;\n startX: number;\n startY: number;\n grabX: number;\n grabY: number;\n x: number;\n y: number;\n width: number;\n started: boolean;\n timer: number;\n frame: number;\n } | null>(null);\n const latest = useRef({ settled, draft });\n latest.current = { settled, draft };\n\n const commit = useCallback(\n (next: LaneBoardLane[], id: string) => {\n const from = whereIs(latest.current.settled, id);\n const to = whereIs(next, id);\n if (!from || !to) return;\n if (from.lane === to.lane && from.index === to.index) return;\n if (controlled === undefined) setInternal(next);\n onLanesChange?.(next);\n onMove?.({\n card: from.card,\n from: { lane: from.lane, index: from.index },\n to: { lane: to.lane, index: to.index },\n });\n },\n [controlled, onLanesChange, onMove]\n );\n\n /*\n * Cards slide to where they now belong. Each card's place is remembered from\n * one drawing to the next; a card found somewhere else in its lane is\n * started from where it was and let go. Places are where a card is laid\n * out, so scrolling the page, the board or a lane between two drawings\n * moves nothing. A card that has changed lanes is not slid: each lane clips\n * its own cards, and it would be seen coming in through the lane's side.\n */\n useLayoutEffect(() => {\n const board = root.current;\n if (!board) return;\n const still = reducedMotion();\n const laneOf = new Map();\n for (const lane of shown) for (const card of lane.cards) laneOf.set(card.id, lane.id);\n const next = new Map();\n cards.current.forEach((node, id) => {\n // Where the card is laid out, measured from the board. Not its box on\n // the page: a card that is still sliding has a box that is on its way,\n // and what is wanted here is where it is going.\n const place = {\n ...placeIn(node, board),\n height: node.offsetHeight,\n lane: laneOf.get(id) ?? '',\n };\n next.set(id, place);\n const before = places.current.get(id);\n if (!before || still || before.lane !== place.lane) return;\n const dx = before.x - place.x;\n const dy = before.y - place.y;\n if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;\n node.animate([{ translate: `${dx}px ${dy}px` }, { translate: '0 0' }], {\n duration: 220,\n easing: 'cubic-bezier(0.2, 0, 0, 1)',\n });\n });\n places.current = next;\n });\n\n /*\n * Only for a board left to grow, with `height=\"auto\"`. It is then as tall\n * as its tallest lane, and which lane that is changes as cards move. Left\n * to follow, the board would grow and shrink under the pointer in the\n * middle of a drag, and again when the card is set down. So it keeps the\n * greatest height it has had, and only ever grows.\n */\n const tallest = useRef(0);\n useLayoutEffect(() => {\n const lanes = [...sections.current.values()];\n for (const lane of lanes) lane.style.minHeight = '';\n if (height !== 'auto') {\n tallest.current = 0;\n return;\n }\n const natural = Math.max(0, ...lanes.map((lane) => lane.offsetHeight));\n // Only what has been set down counts. A card carried across a full lane\n // makes it taller for a moment, and that is not a height to keep.\n if (!carry) tallest.current = Math.max(tallest.current, natural);\n const keep = Math.max(tallest.current, natural);\n for (const lane of lanes) lane.style.minHeight = `${keep}px`;\n });\n\n /*\n * Brings a card's place into view: its lane across the board, and the card\n * down its lane. Worked out from where things are laid out, since a card\n * that has just moved is still drawn on its way there.\n */\n const reveal = (id: string) => {\n const board = root.current;\n const spot = places.current.get(id);\n if (!board || !spot) return;\n const across = scroller.current;\n const section = sections.current.get(spot.lane);\n if (across && section) {\n const before = placeIn(section, board).x - across.scrollLeft;\n const after = before + section.offsetWidth - across.clientWidth;\n if (before < 0) across.scrollLeft += before;\n else if (after > 0) across.scrollLeft += after;\n }\n const lane = viewports.current.get(spot.lane);\n if (lane) {\n // A little clear of the edge, where cards fade out.\n const above = spot.y - placeIn(lane, board).y - lane.scrollTop - 8;\n const below = above + spot.height + 16 - lane.clientHeight;\n if (above < 0) lane.scrollTop += above;\n else if (below > 0) lane.scrollTop += below;\n }\n };\n\n /* ------------------------------------------------------------------ */\n /* Carrying with the pointer */\n /* ------------------------------------------------------------------ */\n\n /** Which lane, and where in it, the carried card is over. */\n const target = (x: number, y: number, id: string) => {\n let lane: string | null = null;\n let nearest = Infinity;\n sections.current.forEach((node, laneId) => {\n const box = node.getBoundingClientRect();\n const distance = x < box.left ? box.left - x : x > box.right ? x - box.right : 0;\n if (distance < nearest) {\n nearest = distance;\n lane = laneId;\n }\n });\n if (lane === null) return null;\n const over = (latest.current.draft ?? latest.current.settled).find((entry) => entry.id === lane);\n /*\n * Measured against where each card belongs, as last laid out, and not\n * where it is drawn. Cards slide to their places, and a card read half\n * way through its slide can be on the wrong side of the pointer, which\n * moves the gap, which slides the card back: a flicker with no end.\n * A place is where a card is laid out, so how far its lane has been\n * scrolled is taken off the pointer and not off the card.\n */\n const top = root.current?.getBoundingClientRect().top ?? 0;\n const scrolled = viewports.current.get(lane)?.scrollTop ?? 0;\n let index = 0;\n for (const card of over?.cards ?? []) {\n if (card.id === id) continue;\n const place = places.current.get(card.id);\n if (place && y - top + scrolled > place.y + place.height / 2) index += 1;\n }\n return { lane: lane as string, index };\n };\n\n const place = () => {\n const state = drag.current;\n const node = overlay.current;\n if (!state || !node) return;\n node.style.translate = `${state.x - state.grabX}px ${state.y - state.grabY}px`;\n };\n\n const follow = () => {\n const state = drag.current;\n if (!state?.started) return;\n const over = target(state.x, state.y, state.id);\n if (over) {\n const now = whereIs(latest.current.draft ?? latest.current.settled, state.id);\n if (!now || now.lane !== over.lane || now.index !== over.index) {\n setDraft(moved(latest.current.settled, state.id, over.lane, over.index));\n }\n }\n // Near either end of the board, it scrolls towards that end.\n const board = scroller.current;\n if (board) {\n const box = board.getBoundingClientRect();\n if (state.x < box.left + EDGE) board.scrollLeft -= 10;\n else if (state.x > box.right - EDGE) board.scrollLeft += 10;\n }\n // And near the top or bottom of the lane it is over, that lane scrolls.\n const lane = over ? viewports.current.get(over.lane) : undefined;\n if (lane) {\n const box = lane.getBoundingClientRect();\n if (state.y < box.top + EDGE) lane.scrollTop -= 8;\n else if (state.y > box.bottom - EDGE) lane.scrollTop += 8;\n }\n state.frame = requestAnimationFrame(follow);\n };\n\n const begin = () => {\n const state = drag.current;\n if (!state || state.started) return;\n state.started = true;\n setDraft(latest.current.settled);\n setCarry({ id: state.id, by: 'pointer' });\n state.frame = requestAnimationFrame(follow);\n };\n\n const end = (drop: boolean) => {\n const state = drag.current;\n if (!state) return;\n window.clearTimeout(state.timer);\n cancelAnimationFrame(state.frame);\n drag.current = null;\n if (!state.started) return;\n const next = latest.current.draft;\n // Put back, the gap goes home first, so there is somewhere to fly to.\n if (!drop) setDraft(latest.current.settled);\n\n const finish = () => {\n if (drop && next) commit(next, state.id);\n setDraft(null);\n setCarry(null);\n };\n /*\n * The lifted card flies the last of the way into the gap that is waiting\n * for it, and only then is the real card shown. Dropped where the pointer\n * let go, it would vanish from one place and appear in another.\n */\n requestAnimationFrame(() => {\n const lifted = overlay.current;\n const board = root.current;\n const slot = places.current.get(state.id);\n if (!lifted || !board || !slot || reducedMotion()) return finish();\n // The gap may have been left under an edge of its lane. It is brought\n // into view first, so the card flies to somewhere that can be seen.\n reveal(state.id);\n const lane = viewports.current.get(slot.lane);\n const box = board.getBoundingClientRect();\n const left = box.left + slot.x - (scroller.current?.scrollLeft ?? 0);\n const top = box.top + slot.y - (lane?.scrollTop ?? 0);\n const flight = lifted.animate(\n [\n { translate: lifted.style.translate, rotate: '2deg' },\n { translate: `${left}px ${top}px`, rotate: '0deg' },\n ],\n { duration: 180, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'forwards' }\n );\n flight.finished.then(finish, finish);\n });\n };\n\n useEffect(() => {\n const onMove = (event: PointerEvent) => {\n const state = drag.current;\n if (!state || event.pointerId !== state.pointerId) return;\n state.x = event.clientX;\n state.y = event.clientY;\n if (!state.started) {\n const travel = Math.hypot(state.x - state.startX, state.y - state.startY);\n // A finger that moves before the hold is up is scrolling, not lifting.\n if (event.pointerType === 'touch') {\n if (travel > SLOP * 2) end(false);\n return;\n }\n if (travel > SLOP) begin();\n return;\n }\n place();\n };\n const onUp = (event: PointerEvent) => {\n if (drag.current && event.pointerId === drag.current.pointerId) end(true);\n };\n const onCancel = () => end(false);\n const onKey = (event: globalThis.KeyboardEvent) => {\n if (event.key === 'Escape' && drag.current?.started) end(false);\n };\n // A carried card must not also scroll the page under the finger.\n const onTouch = (event: TouchEvent) => {\n if (drag.current?.started) event.preventDefault();\n };\n window.addEventListener('pointermove', onMove);\n window.addEventListener('pointerup', onUp);\n window.addEventListener('pointercancel', onCancel);\n window.addEventListener('keydown', onKey);\n window.addEventListener('touchmove', onTouch, { passive: false });\n return () => {\n window.removeEventListener('pointermove', onMove);\n window.removeEventListener('pointerup', onUp);\n window.removeEventListener('pointercancel', onCancel);\n window.removeEventListener('keydown', onKey);\n window.removeEventListener('touchmove', onTouch);\n end(false);\n };\n // The handlers read everything through refs, so they are set up once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // The lifted card is put under the pointer as soon as it exists.\n useLayoutEffect(() => {\n if (carry?.by === 'pointer') place();\n });\n\n const onPointerDown = (event: ReactPointerEvent, id: string) => {\n if (event.button !== 0 || carry) return;\n // A button or a link inside a card is pressed, not dragged by.\n if ((event.target as HTMLElement).closest('button, a, input, textarea, select, [data-no-drag]')) {\n return;\n }\n const box = event.currentTarget.getBoundingClientRect();\n drag.current = {\n id,\n pointerId: event.pointerId,\n startX: event.clientX,\n startY: event.clientY,\n grabX: event.clientX - box.left,\n grabY: event.clientY - box.top,\n x: event.clientX,\n y: event.clientY,\n width: box.width,\n started: false,\n timer: event.pointerType === 'touch' ? window.setTimeout(begin, HOLD) : 0,\n frame: 0,\n };\n };\n\n /* ------------------------------------------------------------------ */\n /* Carrying with the keyboard */\n /* ------------------------------------------------------------------ */\n\n const say = (card: T, lanes: LaneBoardLane[], lead: string) => {\n const at = whereIs(lanes, card.id);\n const lane = lanes.find((entry) => entry.id === at?.lane);\n if (!at || !lane) return;\n const name = typeof lane.title === 'string' ? lane.title : lane.id;\n setSaid(`${lead} ${cardName(card)}: ${name}, place ${at.index + 1} of ${lane.cards.length}.`);\n };\n\n const onKeyDown = (event: KeyboardEvent, card: T) => {\n if (event.target !== event.currentTarget) return;\n const held = carry?.by === 'keys' && carry.id === card.id;\n\n if (!held) {\n if (carry || (event.key !== ' ' && event.key !== 'Enter')) return;\n event.preventDefault();\n setDraft(settled);\n setCarry({ id: card.id, by: 'keys' });\n setSaid(\n `Picked up ${cardName(card)}. Arrow keys move it, Space sets it down, Escape puts it back.`\n );\n return;\n }\n\n const lanes = draft ?? settled;\n const at = whereIs(lanes, card.id);\n if (!at) return;\n const laneAt = lanes.findIndex((lane) => lane.id === at.lane);\n const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';\n\n if (event.key === ' ' || event.key === 'Enter') {\n event.preventDefault();\n commit(lanes, card.id);\n say(card, lanes, 'Set down');\n setDraft(null);\n setCarry(null);\n return;\n }\n if (event.key === 'Escape') {\n event.preventDefault();\n setDraft(null);\n setCarry(null);\n setSaid(`Put ${cardName(card)} back.`);\n return;\n }\n\n let next: LaneBoardLane[] | null = null;\n if (event.key === 'ArrowUp') next = moved(lanes, card.id, at.lane, at.index - 1);\n else if (event.key === 'ArrowDown') next = moved(lanes, card.id, at.lane, at.index + 1);\n else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {\n const step = (event.key === 'ArrowRight' ? 1 : -1) * (rtl ? -1 : 1);\n const other = lanes[laneAt + step];\n if (other) next = moved(lanes, card.id, other.id, at.index);\n }\n if (!next) return;\n event.preventDefault();\n setDraft(next);\n say(card, next, 'Moved');\n };\n\n // Moving to another lane redraws the card under a new parent, which would\n // drop the focus it is being steered by.\n // A move within a long lane can also take it under the lane's edge, where\n // focus alone would not bring it back.\n useLayoutEffect(() => {\n if (carry?.by !== 'keys') return;\n cards.current.get(carry.id)?.focus({ preventScroll: true });\n reveal(carry.id);\n });\n\n const carried = carry ? whereIs(shown, carry.id) : null;\n const carriedLane = carried ? shown.find((lane) => lane.id === carried.lane) : undefined;\n\n return (\n \n
\n \n \n {shown.map((lane) => (\n
{\n if (node) sections.current.set(lane.id, node);\n else sections.current.delete(lane.id);\n }}\n aria-label={typeof lane.title === 'string' ? lane.title : undefined}\n data-over={carried?.lane === lane.id ? '' : undefined}\n className={laneBoardVariants({ over: carried?.lane === lane.id }).lane()}\n >\n \n \n {\n if (node) viewports.current.set(lane.id, node);\n else viewports.current.delete(lane.id);\n }}\n className={slots.laneViewport()}\n >\n \n {lane.cards.map((card) => {\n const isCarried = carry?.id === card.id;\n const state = !isCarried\n ? 'rest'\n : carry.by === 'pointer'\n ? 'placeholder'\n : 'held';\n return (\n {\n if (node) cards.current.set(card.id, node);\n else cards.current.delete(card.id);\n }}\n tabIndex={0}\n aria-roledescription=\"Card that can be moved\"\n aria-label={cardName(card)}\n data-card={card.id}\n data-carried={isCarried ? carry.by : undefined}\n className={laneBoardVariants({ state }).card()}\n onPointerDown={(event) => onPointerDown(event, card.id)}\n onKeyDown={(event) => onKeyDown(event, card)}\n onBlur={() => {\n // Focus leaving a card held with the keyboard puts it\n // back: nothing is steering it any more.\n if (\n carry?.by === 'keys' &&\n carry.id === card.id &&\n !cards.current.get(card.id)?.matches(':focus')\n ) {\n requestAnimationFrame(() => {\n if (document.activeElement !== cards.current.get(card.id)) {\n setDraft(null);\n setCarry(null);\n }\n });\n }\n }}\n >\n {renderCard(card, { lane, carried: isCarried })}\n \n );\n })}\n \n \n \n \n \n \n {renderLaneFooter?.(lane)}
\n \n ))}\n
\n \n \n \n \n \n\n {carry?.by === 'pointer' && carried && carriedLane ? (\n
\n {renderCard(carried.card, { lane: carriedLane, carried: true })}\n
\n ) : null}\n\n
\n {said}\n \n
\n );\n}\nLaneBoardRoot.displayName = 'LaneBoard';\n\n/* -------------------------------------------------------------------------- */\n/* What goes on a card */\n/* -------------------------------------------------------------------------- */\n\nexport interface LaneBoardTitleProps extends ComponentProps<'div'> {}\n\n/** A card's name. */\nfunction LaneBoardTitle({ className, ...props }: LaneBoardTitleProps) {\n return
;\n}\nLaneBoardTitle.displayName = 'LaneBoard.Title';\n\nexport interface LaneBoardCodeProps extends ComponentProps<'div'> {}\n\n/** A card's reference, in a fixed-width face so a column of them lines up. */\nfunction LaneBoardCode({ className, ...props }: LaneBoardCodeProps) {\n return
;\n}\nLaneBoardCode.displayName = 'LaneBoard.Code';\n\nexport interface LaneBoardRowProps extends ComponentProps<'div'> {}\n\n/** The last line of a card: something at each end. */\nfunction LaneBoardRow({ className, ...props }: LaneBoardRowProps) {\n return
;\n}\nLaneBoardRow.displayName = 'LaneBoard.Row';\n\nexport interface LaneBoardStepsProps extends Omit, 'children' | 'color'> {\n /** How many steps are done. */\n value: number;\n /** How many steps there are. */\n max?: number;\n /** Which of the theme's roles the done steps are drawn in. */\n color?: LaneBoardColor;\n /** What a screen reader calls it. */\n label?: string;\n}\n\n/** How far along a card is, as a row of dashes with the done ones lit. */\nfunction LaneBoardSteps({\n value,\n max = 5,\n color = 'primary',\n label = 'Progress',\n className,\n ...props\n}: LaneBoardStepsProps) {\n const slots = laneBoardVariants({ color });\n return (\n \n {Array.from({ length: max }, (_, index) => (\n \n ))}\n
\n );\n}\nLaneBoardSteps.displayName = 'LaneBoard.Steps';\n\nexport interface LaneBoardTagProps extends Omit, 'color'> {\n /** Which of the theme's roles the tag is drawn in. */\n color?: LaneBoardColor;\n}\n\n/** A word in a pill, for a card's state. */\nfunction LaneBoardTag({ color = 'primary', className, ...props }: LaneBoardTagProps) {\n return ;\n}\nLaneBoardTag.displayName = 'LaneBoard.Tag';\n\nexport interface LaneBoardMetaProps extends ComponentProps<'span'> {}\n\n/** A short figure at the end of a card's last line: a time, a count. */\nfunction LaneBoardMeta({ className, ...props }: LaneBoardMetaProps) {\n return ;\n}\nLaneBoardMeta.displayName = 'LaneBoard.Meta';\n\nexport const LaneBoard = Object.assign(LaneBoardRoot, {\n Title: LaneBoardTitle,\n Code: LaneBoardCode,\n Row: LaneBoardRow,\n Steps: LaneBoardSteps,\n Tag: LaneBoardTag,\n Meta: LaneBoardMeta,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n module. */\nexport { LaneBoardTitle, LaneBoardCode, LaneBoardRow, LaneBoardSteps, LaneBoardTag, LaneBoardMeta };\n"
+ }
+ ]
+}
diff --git a/apps/docs/public/r/web/planet.json b/apps/docs/public/r/web/planet.json
new file mode 100644
index 00000000..7f3b156f
--- /dev/null
+++ b/apps/docs/public/r/web/planet.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "planet",
+ "type": "registry:component",
+ "title": "Planet",
+ "description": "The world as dots on a globe you can turn, with markers pinned to it.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/planet/index.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/planet/index.tsx",
+ "content": "'use client';\n\n/**\n * Planet — the world as dots on a globe you can turn, with things pinned to it.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Two layers\n *\n * The globe is a canvas and the markers are ordinary elements laid over it.\n * The land is several thousand dots, which is a job for a canvas; a marker is\n * a label, an avatar, maybe a link, which is a job for the DOM. Each frame the\n * canvas is redrawn and every marker is moved to where its coordinate now is,\n * by a transform written straight to the element. Turning the globe never\n * re-renders React.\n *\n * ## Nothing is drawn that is not asked for\n *\n * There is no loop running while the globe is still. A drag, a press on a\n * control, a resize or a change of theme asks for one frame, and a frame asks\n * for another only while something is still moving.\n *\n * ## The land is a question, not a shape\n *\n * `land.ts` says, per square degree, whether it is land. A grid of points is\n * laid over the sphere at an even spacing and the ones on land are kept, so\n * the coastlines come out as the edge of a dot pattern. The spacing is in\n * pixels, so a small globe and a large one are dotted equally finely and a\n * small one is not a smear.\n *\n * ## It takes the text colour\n *\n * The dots and the shading are drawn in `currentColor`, read from the element\n * each frame. On a dark surface the land is light and on a light one it is\n * dark, with no colour passed in and nothing to keep in step with the theme.\n */\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type MouseEvent,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { LAND, LAND_COLUMNS, LAND_ROWS } from './land';\n\nconst planetVariants = tv({\n slots: {\n root: 'flex w-full flex-col items-center gap-4 text-foreground',\n // `touch-pan-y` leaves a vertical swipe to the page. A globe that traps\n // the scroll it sits in is worse than one that only turns sideways.\n globe:\n 'relative aspect-square w-full max-w-md cursor-grab touch-pan-y select-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background active:cursor-grabbing',\n // The glow's colour is put in a real colour property, so the browser\n // resolves it and the drawing code can read back a colour it can use.\n canvas: 'absolute inset-0 size-full [outline-color:var(--pu-glow)]',\n markers: 'pointer-events-none absolute inset-0',\n // Opacity 0 until the first frame has placed it, so a marker is never\n // seen stacked in the corner it starts in.\n marker:\n 'pointer-events-auto absolute left-0 top-0 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center opacity-0 will-change-transform',\n // The same label and pin the app library's Map draws: opaque enough to\n // read over the land, with an edge and a shadow so it sits on the globe\n // and not in it.\n // The stem under the label ties it to its pin, so two labels close\n // together can still be told apart.\n label:\n 'absolute bottom-full mb-2.5 flex items-center gap-1.5 whitespace-nowrap rounded-md border bg-background/95 px-1.5 py-0.5 text-xs font-medium text-foreground shadow-sm after:absolute after:left-1/2 after:top-full after:h-2 after:w-px after:-translate-x-1/2 after:bg-foreground/40',\n count: 'tabular-nums text-muted-foreground',\n // An opaque surface. A tint would let the land show through whatever the\n // pin holds, and initials over a field of dots cannot be read.\n pin: 'relative flex size-8 items-center justify-center overflow-hidden rounded-full border-2 border-[var(--pu-color)] bg-card text-card-foreground shadow-md ring-2 ring-background',\n dot: 'relative size-3.5 rounded-full border-2 border-background bg-[var(--pu-color)] shadow-md',\n // A marker that can be pressed. The button has no box of its own, so the\n // label above it is still placed from the marker and pressing either the\n // label or the pin counts.\n press:\n 'group/marker flex cursor-pointer flex-col items-center rounded-full outline-none [&>[data-pin]]:transition-[scale,box-shadow] [&>[data-pin]]:duration-200 hover:[&>[data-pin]]:scale-125 focus-visible:[&>[data-pin]]:ring-2 focus-visible:[&>[data-pin]]:ring-ring active:[&>[data-pin]]:scale-110',\n // A ring that spreads from the marker and fades, for one that wants\n // looking at. Still for anyone who has asked for reduced motion.\n pulse:\n 'pointer-events-none absolute left-1/2 top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 animate-ping rounded-full bg-[var(--pu-color)] opacity-70 motion-reduce:hidden',\n // One surface split by a hairline, not two buttons with a gap between.\n controls:\n 'inline-flex items-stretch divide-x divide-border overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm',\n control:\n 'inline-flex size-9 cursor-pointer items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground active:bg-muted',\n caption: 'flex items-center px-3 text-sm font-medium',\n },\n variants: {\n /** The colour of the light at the globe's edge. */\n glow: {\n primary: { globe: '[--pu-glow:var(--primary)]' },\n info: { globe: '[--pu-glow:var(--info)]' },\n success: { globe: '[--pu-glow:var(--success)]' },\n warning: { globe: '[--pu-glow:var(--warning)]' },\n destructive: { globe: '[--pu-glow:var(--destructive)]' },\n },\n // The marker that has been picked: larger, with a ring of its own colour.\n selected: {\n true: {\n dot: 'scale-125 shadow-[0_0_0_4px_color-mix(in_oklab,var(--pu-color)_35%,transparent)]',\n pin: 'scale-110 shadow-[0_0_0_4px_color-mix(in_oklab,var(--pu-color)_35%,transparent)]',\n marker: 'z-10',\n },\n },\n color: {\n primary: { marker: '[--pu-color:var(--primary)]' },\n info: { marker: '[--pu-color:var(--info)]' },\n success: { marker: '[--pu-color:var(--success)]' },\n warning: { marker: '[--pu-color:var(--warning)]' },\n destructive: { marker: '[--pu-color:var(--destructive)]' },\n },\n },\n defaultVariants: { color: 'primary', glow: 'primary' },\n});\n\n/* -------------------------------------------------------------------------- */\n/* The land */\n/* -------------------------------------------------------------------------- */\n\nconst RADIANS = Math.PI / 180;\n\n/** The globe's radius as a share of half its box. The rest is the glow. */\nconst SPHERE = 0.9;\n\n/** The points of a dot grid that fall on land, as unit vectors. */\ninterface Dots {\n x: Float32Array;\n y: Float32Array;\n z: Float32Array;\n count: number;\n}\n\nlet landBits: Uint8Array | null = null;\n\nfunction isLand(latitude: number, longitude: number): boolean {\n if (!landBits) {\n const decoded = atob(LAND);\n landBits = new Uint8Array(decoded.length);\n for (let i = 0; i < decoded.length; i += 1) landBits[i] = decoded.charCodeAt(i);\n }\n const row = Math.min(LAND_ROWS - 1, Math.max(0, Math.floor(((90 - latitude) / 180) * LAND_ROWS)));\n const column = Math.min(\n LAND_COLUMNS - 1,\n Math.max(0, Math.floor(((longitude + 180) / 360) * LAND_COLUMNS))\n );\n const cell = row * LAND_COLUMNS + column;\n return ((landBits[cell >> 3] ?? 0) & (1 << (cell & 7))) !== 0;\n}\n\nconst dotCache = new Map();\n\n/**\n * A grid with `step` degrees between rows, and between the points along each\n * row at the equator. A row nearer a pole is a shorter circle and gets fewer\n * points, so the spacing on the sphere is the same everywhere and the dots do\n * not crowd together towards the top.\n */\nfunction dotsFor(step: number): Dots {\n const cached = dotCache.get(step);\n if (cached) return cached;\n\n const xs: number[] = [];\n const ys: number[] = [];\n const zs: number[] = [];\n const rows = Math.round(180 / step);\n for (let row = 0; row < rows; row += 1) {\n const latitude = 90 - (row + 0.5) * (180 / rows);\n const cos = Math.cos(latitude * RADIANS);\n const sin = Math.sin(latitude * RADIANS);\n const columns = Math.max(1, Math.round((360 / step) * cos));\n for (let column = 0; column < columns; column += 1) {\n const longitude = -180 + (column + 0.5) * (360 / columns);\n if (!isLand(latitude, longitude)) continue;\n xs.push(cos * Math.sin(longitude * RADIANS));\n ys.push(sin);\n zs.push(cos * Math.cos(longitude * RADIANS));\n }\n }\n\n const dots = {\n x: Float32Array.from(xs),\n y: Float32Array.from(ys),\n z: Float32Array.from(zs),\n count: xs.length,\n };\n dotCache.set(step, dots);\n return dots;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Context */\n/* -------------------------------------------------------------------------- */\n\n/** `[longitude, latitude]` of the point facing the viewer, in degrees. */\nexport type PlanetRotation = [longitude: number, latitude: number];\n\n/** Where a coordinate is on the globe's box, and how far towards the viewer. */\ninterface Projected {\n x: number;\n y: number;\n /** 1 at the centre, 0 on the edge, negative on the far side. */\n depth: number;\n}\n\ntype Project = (latitude: number, longitude: number) => Projected;\n\ninterface PlanetContextValue {\n /** Called with the current projection on every frame the globe draws. */\n subscribe(listener: (project: Project) => void): () => void;\n /** The set `subscribe` adds to, which the globe hands to its engine. */\n listeners: Set<(project: Project) => void>;\n rotateBy(longitude: number, latitude?: number): void;\n rotateTo(rotation: PlanetRotation): void;\n /** The globe's own loop, started by `Planet.Globe` once it has a canvas. */\n engine: React.RefObject;\n defaultRotation: PlanetRotation;\n autoRotate: number;\n interactive: boolean;\n onRotationChange?: (rotation: PlanetRotation) => void;\n}\n\nconst PlanetContext = createContext(null);\n\nfunction usePlanetContext(part: string): PlanetContextValue {\n const context = useContext(PlanetContext);\n if (!context) throw new Error(`${part} must be used inside a .`);\n return context;\n}\n\nexport interface PlanetHandle {\n /** Turn by this many degrees of longitude, and optionally latitude. */\n rotateBy(longitude: number, latitude?: number): void;\n /** Turn until this coordinate faces the viewer. */\n rotateTo(rotation: PlanetRotation): void;\n /** The coordinate facing the viewer right now. */\n getRotation(): PlanetRotation;\n}\n\n/* -------------------------------------------------------------------------- */\n/* The engine */\n/* -------------------------------------------------------------------------- */\n\n/** How far the globe can be tipped. Past this the far pole comes into view. */\nconst MAX_TILT = 60;\n/** Pixels between neighbouring dots, whatever size the globe is. */\nconst DOT_SPACING = 4.4;\n/** Where the light comes from: in front, a little above and to the left. */\nconst LIGHT = { x: -0.3, y: 0.42, z: 0.86 };\nconst ALPHA_STEPS = 8;\n\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n/** A longitude brought back into -180..180, so it never grows without bound. */\nconst wrap = (longitude: number) => ((((longitude + 180) % 360) + 360) % 360) - 180;\n/** The short way round from one longitude to another. */\nconst shortest = (from: number, to: number) => wrap(to - from);\n\ninterface Engine {\n rotateBy(longitude: number, latitude?: number): void;\n rotateTo(rotation: PlanetRotation): void;\n getRotation(): PlanetRotation;\n setAutoRotate(speed: number): void;\n dragStart(): void;\n dragMove(dx: number, dy: number, vertical: boolean): void;\n dragEnd(): void;\n setHover(hovering: boolean): void;\n invalidate(): void;\n destroy(): void;\n}\n\ninterface EngineOptions {\n canvas: HTMLCanvasElement;\n rotation: PlanetRotation;\n autoRotate: number;\n listeners: Set<(project: Project) => void>;\n onSettle(rotation: PlanetRotation): void;\n}\n\nfunction createEngine({ canvas, rotation, autoRotate, listeners, onSettle }: EngineOptions): Engine {\n const context = canvas.getContext('2d');\n const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');\n\n let longitude = wrap(rotation[0]);\n let latitude = clamp(rotation[1], -MAX_TILT, MAX_TILT);\n /** Where an eased turn is heading, or `null` when none is under way. */\n let target: { longitude: number; latitude: number } | null = null;\n /** Degrees a second the globe is still coasting at after a drag. */\n let velocity = 0;\n let spin = autoRotate;\n let dragging = false;\n let hovering = false;\n let visible = true;\n let frame = 0;\n let last = 0;\n let size = 0;\n let destroyed = false;\n /** Samples of recent drag movement, to turn a flick into a coast. */\n let samples: { time: number; dx: number }[] = [];\n\n const draw = () => {\n if (!context || size === 0) return;\n const ratio = window.devicePixelRatio || 1;\n const centre = size / 2;\n // Smaller than the canvas, which leaves a margin for the light round it.\n const radius = (size / 2) * SPHERE;\n const style = getComputedStyle(canvas);\n const color = style.color;\n const glow = style.outlineColor || color;\n\n context.setTransform(ratio, 0, 0, ratio, 0, 0);\n context.clearRect(0, 0, size, size);\n\n /*\n * The light round the globe: a ring that starts at the edge and fades\n * outwards. Drawn as a ring and not a disc, because a gradient paints its\n * first colour all the way in to the middle.\n */\n const halo = context.createRadialGradient(centre, centre, radius * 0.98, centre, centre, size / 2);\n halo.addColorStop(0, glow);\n halo.addColorStop(1, 'transparent');\n context.fillStyle = halo;\n // Faint on purpose. Any stronger and it washes over the land near the\n // edge, which is where the dots are already at their quietest.\n context.globalAlpha = 0.14;\n context.beginPath();\n context.arc(centre, centre, size / 2, 0, Math.PI * 2);\n context.arc(centre, centre, radius, 0, Math.PI * 2, true);\n context.fill('evenodd');\n context.globalAlpha = 1;\n\n /* The sphere itself: a faint disc, lit from the upper left. */\n context.save();\n context.beginPath();\n context.arc(centre, centre, radius, 0, Math.PI * 2);\n context.clip();\n context.fillStyle = color;\n context.globalAlpha = 0.035;\n context.fillRect(0, 0, size, size);\n const lit = context.createRadialGradient(\n centre - radius * 0.42,\n centre - radius * 0.48,\n radius * 0.05,\n centre - radius * 0.2,\n centre - radius * 0.2,\n radius * 1.15\n );\n lit.addColorStop(0, color);\n lit.addColorStop(1, 'transparent');\n context.fillStyle = lit;\n context.globalAlpha = 0.11;\n context.fillRect(0, 0, size, size);\n context.restore();\n\n /* The land. */\n const sinLongitude = Math.sin(longitude * RADIANS);\n const cosLongitude = Math.cos(longitude * RADIANS);\n const sinLatitude = Math.sin(latitude * RADIANS);\n const cosLatitude = Math.cos(latitude * RADIANS);\n\n // Finer than a degree buys nothing: that is what the land grid resolves.\n const step = clamp(Math.round((DOT_SPACING / (radius * RADIANS)) * 10) / 10, 1, 6);\n const dots = dotsFor(step);\n const dotRadius = Math.max(0.6, radius * 0.0062);\n\n // One path per level of brightness. Filling several thousand dots one at a\n // time is several thousand state changes; this is eight.\n const paths: Path2D[] = Array.from({ length: ALPHA_STEPS }, () => new Path2D());\n for (let i = 0; i < dots.count; i += 1) {\n const dx = dots.x[i] ?? 0;\n const dy = dots.y[i] ?? 0;\n const dz = dots.z[i] ?? 0;\n // Turn about the vertical axis, then tip towards the viewer.\n const x = dx * cosLongitude - dz * sinLongitude;\n const forward = dx * sinLongitude + dz * cosLongitude;\n const depth = dy * sinLatitude + forward * cosLatitude;\n if (depth <= 0) continue;\n const y = dy * cosLatitude - forward * sinLatitude;\n\n const light = Math.max(0, x * LIGHT.x + y * LIGHT.y + depth * LIGHT.z);\n // Dots fade out towards the edge. They are seen nearly side on there,\n // packed into lines, and at full strength the rim reads as a bright ring\n // around a globe whose middle is the part worth looking at.\n const turned = Math.min(1, depth / 0.55);\n const edge = turned * turned * (3 - 2 * turned);\n const alpha = (0.22 + 0.78 * light) * edge;\n const level = Math.min(ALPHA_STEPS - 1, Math.floor(alpha * ALPHA_STEPS));\n const path = paths[level];\n if (!path) continue;\n const px = centre + x * radius;\n const py = centre - y * radius;\n // Dots facing the viewer are drawn a little larger than those turned\n // away, as they would be seen.\n const r = dotRadius * (0.72 + 0.42 * depth);\n path.moveTo(px + r, py);\n path.arc(px, py, r, 0, Math.PI * 2);\n }\n context.fillStyle = color;\n paths.forEach((path, level) => {\n context.globalAlpha = (level + 0.5) / ALPHA_STEPS;\n context.fill(path);\n });\n context.globalAlpha = 1;\n\n /* The markers, which are told where they are rather than drawn here. */\n const project: Project = (markerLatitude, markerLongitude) => {\n const cos = Math.cos(markerLatitude * RADIANS);\n const sin = Math.sin(markerLatitude * RADIANS);\n const dx = cos * Math.sin(markerLongitude * RADIANS);\n const dz = cos * Math.cos(markerLongitude * RADIANS);\n const x = dx * cosLongitude - dz * sinLongitude;\n const forward = dx * sinLongitude + dz * cosLongitude;\n return {\n x: centre + x * radius,\n y: centre - (sin * cosLatitude - forward * sinLatitude) * radius,\n depth: sin * sinLatitude + forward * cosLatitude,\n };\n };\n for (const listener of listeners) listener(project);\n };\n\n const resting = () => {\n const turning = spin !== 0 && !hovering && !dragging && visible && !reducedMotion.matches;\n return !dragging && target === null && Math.abs(velocity) < 1 && !turning;\n };\n\n const tick = (now: number) => {\n frame = 0;\n if (destroyed) return;\n const elapsed = last === 0 ? 0 : Math.min(0.05, (now - last) / 1000);\n last = now;\n\n if (target) {\n // Closes most of the remaining distance each frame, so a turn starts\n // quickly and settles gently however far it has to go.\n const ease = reducedMotion.matches ? 1 : 1 - Math.exp(-elapsed * 6);\n const remaining = shortest(longitude, target.longitude);\n longitude = wrap(longitude + remaining * ease);\n latitude += (target.latitude - latitude) * ease;\n if (Math.abs(remaining) < 0.05 && Math.abs(target.latitude - latitude) < 0.05) {\n longitude = target.longitude;\n latitude = target.latitude;\n target = null;\n }\n } else if (!dragging && Math.abs(velocity) >= 1) {\n longitude = wrap(longitude + velocity * elapsed);\n velocity *= Math.exp(-elapsed * 3.2);\n } else if (!dragging && !hovering && visible && spin !== 0 && !reducedMotion.matches) {\n longitude = wrap(longitude + spin * elapsed);\n }\n\n draw();\n\n if (resting()) {\n velocity = 0;\n last = 0;\n onSettle([longitude, latitude]);\n return;\n }\n frame = requestAnimationFrame(tick);\n };\n\n const invalidate = () => {\n if (frame === 0 && !destroyed) frame = requestAnimationFrame(tick);\n };\n\n /* Size the canvas to its box, at the density of the screen it is on. */\n const resize = new ResizeObserver(() => {\n const next = Math.round(canvas.clientWidth);\n if (next === 0) return;\n const ratio = window.devicePixelRatio || 1;\n size = next;\n canvas.width = Math.round(next * ratio);\n canvas.height = Math.round(next * ratio);\n // Synchronously, not on the next frame: setting the size cleared the\n // canvas, and waiting a frame to redraw would flash it empty.\n draw();\n invalidate();\n });\n resize.observe(canvas);\n\n /* A theme change repaints nothing by itself, since nothing is animating. */\n const theme = new MutationObserver(invalidate);\n theme.observe(document.documentElement, {\n attributes: true,\n attributeFilter: ['class', 'style', 'data-theme'],\n });\n\n /* Turning on its own stops while the globe is off screen. */\n const viewport = new IntersectionObserver(([entry]) => {\n visible = entry?.isIntersecting ?? true;\n if (visible) invalidate();\n });\n viewport.observe(canvas);\n\n const onMotionPreference = () => invalidate();\n reducedMotion.addEventListener('change', onMotionPreference);\n\n return {\n rotateBy(byLongitude, byLatitude = 0) {\n const from = target ?? { longitude, latitude };\n target = {\n longitude: wrap(from.longitude + byLongitude),\n latitude: clamp(from.latitude + byLatitude, -MAX_TILT, MAX_TILT),\n };\n velocity = 0;\n invalidate();\n },\n rotateTo([toLongitude, toLatitude]) {\n target = { longitude: wrap(toLongitude), latitude: clamp(toLatitude, -MAX_TILT, MAX_TILT) };\n velocity = 0;\n invalidate();\n },\n getRotation: () => [longitude, latitude],\n setAutoRotate(speed) {\n spin = speed;\n invalidate();\n },\n dragStart() {\n dragging = true;\n target = null;\n velocity = 0;\n samples = [];\n invalidate();\n },\n dragMove(dx, dy, vertical) {\n if (!dragging || size === 0) return;\n // A point on the surface stays under the pointer: an arc the length of\n // the movement, on a sphere of this radius.\n const degrees = 1 / ((size / 2) * RADIANS);\n longitude = wrap(longitude - dx * degrees);\n if (vertical) latitude = clamp(latitude + dy * degrees, -MAX_TILT, MAX_TILT);\n const now = performance.now();\n samples.push({ time: now, dx: -dx * degrees });\n samples = samples.filter((sample) => now - sample.time < 120);\n invalidate();\n },\n dragEnd() {\n if (!dragging) return;\n dragging = false;\n const now = performance.now();\n const recent = samples.filter((sample) => now - sample.time < 120);\n const first = recent[0];\n if (first && recent.length > 1 && !reducedMotion.matches) {\n const span = Math.max(16, now - first.time) / 1000;\n velocity = clamp(recent.reduce((sum, sample) => sum + sample.dx, 0) / span, -540, 540);\n }\n invalidate();\n },\n setHover(next) {\n hovering = next;\n invalidate();\n },\n invalidate,\n destroy() {\n destroyed = true;\n if (frame !== 0) cancelAnimationFrame(frame);\n resize.disconnect();\n theme.disconnect();\n viewport.disconnect();\n reducedMotion.removeEventListener('change', onMotionPreference);\n },\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlanetProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * The coordinate facing the viewer to begin with, as `[longitude, latitude]`\n * in degrees. Read once: turning the globe afterwards does not change it.\n */\n defaultRotation?: PlanetRotation;\n /**\n * Turn on its own, at this many degrees of longitude a second. `true` is 6.\n * It stops while the pointer is over the globe, while it is being dragged,\n * while it is off screen, and for anyone who has asked for reduced motion.\n */\n autoRotate?: boolean | number;\n /** Let the globe be dragged and turned with the arrow keys. */\n interactive?: boolean;\n /** Called with the coordinate facing the viewer each time the globe comes to rest. */\n onRotationChange?: (rotation: PlanetRotation) => void;\n}\n\nconst PlanetRoot = forwardRef(function PlanetRoot(\n {\n children,\n className,\n defaultRotation = [0, 15],\n autoRotate = false,\n interactive = true,\n onRotationChange,\n ...props\n },\n ref\n) {\n const slots = planetVariants();\n const engine = useRef(null);\n const listeners = useRef(new Set<(project: Project) => void>()).current;\n // The opening view, kept from the first render. The prop says where the\n // globe starts, not where it is.\n const initial = useRef(defaultRotation).current;\n const speed = autoRotate === true ? 6 : autoRotate === false ? 0 : autoRotate;\n\n const subscribe = useCallback(\n (listener: (project: Project) => void) => {\n listeners.add(listener);\n // A marker added later is placed straight away, not on the next turn.\n engine.current?.invalidate();\n return () => {\n listeners.delete(listener);\n };\n },\n [listeners]\n );\n\n const rotateBy = useCallback((longitude: number, latitude?: number) => {\n engine.current?.rotateBy(longitude, latitude);\n }, []);\n const rotateTo = useCallback((rotation: PlanetRotation) => {\n engine.current?.rotateTo(rotation);\n }, []);\n\n useImperativeHandle(\n ref,\n () => ({\n rotateBy,\n rotateTo,\n getRotation: () => engine.current?.getRotation() ?? initial,\n }),\n [rotateBy, rotateTo, initial]\n );\n\n const context = useMemo(\n () => ({\n subscribe,\n listeners,\n rotateBy,\n rotateTo,\n engine,\n defaultRotation: initial,\n autoRotate: speed,\n interactive,\n onRotationChange,\n }),\n [subscribe, listeners, rotateBy, rotateTo, initial, speed, interactive, onRotationChange]\n );\n\n return (\n \n \n {children}\n
\n \n );\n});\nPlanetRoot.displayName = 'Planet';\n\n/* -------------------------------------------------------------------------- */\n/* Globe */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlanetGlobeProps extends Omit, 'children' | 'color'> {\n /** The markers. Anything else placed here is laid over the globe unmoved. */\n children?: ReactNode;\n /** Which of the theme's roles the light round the globe is drawn in. */\n color?: PlanetMarkerColor;\n /** What a screen reader calls the globe. */\n label?: string;\n}\n\n/** Degrees one press of an arrow key turns the globe. */\nconst KEY_STEP = 15;\n\nfunction PlanetGlobe({\n children,\n className,\n color = 'primary',\n label = 'Globe',\n ...props\n}: PlanetGlobeProps) {\n const slots = planetVariants({ glow: color });\n const planet = usePlanetContext('Planet.Globe');\n const canvas = useRef(null);\n const pointer = useRef<{ id: number; x: number; y: number; vertical: boolean } | null>(null);\n // Where the globe has come to rest, for the slider semantics below. Updated\n // when it settles, not while it turns, so turning never re-renders.\n const [settled, setSettled] = useState(planet.defaultRotation);\n\n // Read through a ref so a new callback from the parent does not rebuild the\n // engine and throw away where the globe has been turned to.\n const onRotationChange = useRef(planet.onRotationChange);\n onRotationChange.current = planet.onRotationChange;\n\n const { engine, defaultRotation, listeners } = planet;\n // The glow is read off the canvas when it draws, so a new colour needs a\n // new drawing to be seen.\n useEffect(() => {\n engine.current?.invalidate();\n }, [engine, color]);\n\n useEffect(() => {\n if (!canvas.current) return;\n const created = createEngine({\n canvas: canvas.current,\n rotation: defaultRotation,\n autoRotate: 0,\n listeners,\n onSettle: (rotation) => {\n setSettled((current) =>\n Math.round(current[0]) === Math.round(rotation[0]) &&\n Math.round(current[1]) === Math.round(rotation[1])\n ? current\n : rotation\n );\n onRotationChange.current?.(rotation);\n },\n });\n engine.current = created;\n return () => {\n created.destroy();\n engine.current = null;\n };\n // The engine is built once per canvas. What can change later, the\n // turning speed, is pushed into it by the effect below.\n }, [engine, defaultRotation, listeners]);\n\n useEffect(() => {\n engine.current?.setAutoRotate(planet.autoRotate);\n }, [engine, planet.autoRotate]);\n\n const onPointerDown = (event: ReactPointerEvent) => {\n if (!planet.interactive || event.button !== 0) return;\n // A press that starts on a marker belongs to the marker.\n if ((event.target as HTMLElement).closest('[data-planet-marker]')) return;\n event.currentTarget.setPointerCapture(event.pointerId);\n pointer.current = {\n id: event.pointerId,\n x: event.clientX,\n y: event.clientY,\n // A finger moving vertically is scrolling the page, so only a mouse or a\n // pen tips the globe as well as turning it.\n vertical: event.pointerType !== 'touch',\n };\n engine.current?.dragStart();\n };\n\n const onPointerMove = (event: ReactPointerEvent) => {\n const active = pointer.current;\n if (!active || active.id !== event.pointerId) return;\n engine.current?.dragMove(event.clientX - active.x, event.clientY - active.y, active.vertical);\n active.x = event.clientX;\n active.y = event.clientY;\n };\n\n const endDrag = (event: ReactPointerEvent) => {\n if (pointer.current?.id !== event.pointerId) return;\n pointer.current = null;\n engine.current?.dragEnd();\n };\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (!planet.interactive) return;\n const turn: Record = {\n ArrowLeft: [-KEY_STEP, 0],\n ArrowRight: [KEY_STEP, 0],\n ArrowUp: [0, KEY_STEP],\n ArrowDown: [0, -KEY_STEP],\n };\n const by = turn[event.key];\n if (!by) return;\n event.preventDefault();\n planet.rotateBy(by[0], by[1]);\n };\n\n const longitude = Math.round(settled[0]);\n const side = longitude === 0 ? '' : longitude > 0 ? ' east' : ' west';\n\n return (\n engine.current?.setHover(true)}\n onPointerLeave={() => engine.current?.setHover(false)}\n onKeyDown={onKeyDown}\n {...props}\n >\n
\n
{children}
\n
\n );\n}\nPlanetGlobe.displayName = 'Planet.Globe';\n\n/* -------------------------------------------------------------------------- */\n/* Marker */\n/* -------------------------------------------------------------------------- */\n\n/** Which of the theme's roles a marker's dot is drawn in. */\nexport type PlanetMarkerColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';\n\nexport interface PlanetMarkerProps\n extends Omit, 'children' | 'color' | 'onClick'> {\n /** Degrees north of the equator. South is negative. */\n lat: number;\n /** Degrees east of Greenwich. West is negative. */\n lng: number;\n /** A caption in a tag above the pin. */\n label?: ReactNode;\n /** A figure after the label, in a quieter colour. */\n count?: ReactNode;\n /** Which of the theme's roles the dot, or the ring of a pin with children, is drawn in. */\n color?: PlanetMarkerColor;\n /** Spreads a fading ring from the marker, for one that wants looking at. */\n pulse?: boolean;\n /**\n * Called when the marker is pressed. Passing this makes the marker a\n * button: it takes focus, and Enter or Space press it.\n */\n onClick?: (event: MouseEvent) => void;\n /** Draws the marker as the one that has been picked. */\n selected?: boolean;\n /** What a screen reader calls a marker that can be pressed. Defaults to its label. */\n name?: string;\n /** Extra classes for the label. */\n labelClassName?: string;\n /** What is drawn at the coordinate: an avatar, an icon. A dot when left out. */\n children?: ReactNode;\n}\n\n/** How near the edge a marker starts to fade, as depth. */\nconst FADE_FROM = 0.28;\nconst FADE_TO = 0.06;\n\nfunction PlanetMarker({\n lat,\n lng,\n label,\n count,\n color = 'primary',\n pulse = false,\n onClick,\n selected = false,\n name,\n className,\n labelClassName,\n children,\n ...props\n}: PlanetMarkerProps) {\n const slots = planetVariants({ color, selected });\n const planet = usePlanetContext('Planet.Marker');\n const element = useRef(null);\n const { subscribe } = planet;\n\n useEffect(\n () =>\n subscribe((project) => {\n const node = element.current;\n if (!node) return;\n const { x, y, depth } = project(lat, lng);\n const shown = clamp((depth - FADE_TO) / (FADE_FROM - FADE_TO), 0, 1);\n // `translate` is its own property, so it does not replace the classes'\n // transform that centres the pin on the point.\n node.style.translate = `${x.toFixed(2)}px ${y.toFixed(2)}px`;\n node.style.opacity = shown.toFixed(3);\n node.style.pointerEvents = shown > 0.5 ? '' : 'none';\n // A marker on the far side cannot be seen, so it cannot be tabbed to.\n node.toggleAttribute('inert', shown <= 0.5);\n // Nearer the viewer is on top, as it would be on a real globe.\n node.style.zIndex = String(Math.round(depth * 100));\n }),\n [subscribe, lat, lng]\n );\n\n const body = (\n <>\n {label !== undefined && label !== null ? (\n \n {label}\n {count !== undefined && count !== null ? (\n {count} \n ) : null}\n \n ) : null}\n {pulse ? : null}\n {children !== undefined && children !== null ? (\n \n {children}\n \n ) : (\n \n )}\n >\n );\n\n return (\n \n {onClick ? (\n event.stopPropagation()}\n onKeyDown={(event) => event.stopPropagation()}\n >\n {body}\n \n ) : (\n body\n )}\n
\n );\n}\nPlanetMarker.displayName = 'Planet.Marker';\n\n/* -------------------------------------------------------------------------- */\n/* Controls */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlanetControlsProps extends Omit, 'children'> {\n /** Degrees of longitude one press turns the globe. */\n step?: number;\n /** Words to show between the two arrows. Without them it is the arrows alone. */\n children?: ReactNode;\n}\n\nconst Arrow = ({ flipped }: { flipped?: boolean }) => (\n \n \n \n);\n\nfunction PlanetControls({ step = 60, className, children, ...props }: PlanetControlsProps) {\n const slots = planetVariants();\n const planet = usePlanetContext('Planet.Controls');\n\n return (\n \n
planet.rotateBy(-step)}\n >\n \n \n {children !== undefined && children !== null ? (\n
{children} \n ) : null}\n
planet.rotateBy(step)}\n >\n \n \n
\n );\n}\nPlanetControls.displayName = 'Planet.Controls';\n\nexport const Planet = Object.assign(PlanetRoot, {\n Globe: PlanetGlobe,\n Marker: PlanetMarker,\n Controls: PlanetControls,\n});\n\n/*\n * The parts again, by name. `Planet.Globe` is a property of a component, and a\n * Server Component cannot read a property off something imported from a\n * client module, only pass the import itself along. So a page that is a\n * Server Component writes ``; anywhere else the dotted form is\n * the same thing.\n */\nexport { PlanetGlobe, PlanetMarker, PlanetControls };\n"
+ },
+ {
+ "path": "components/panelui/planet/land.ts",
+ "type": "registry:component",
+ "target": "@components/panelui/planet/land.ts",
+ "content": "// Generated by scripts/generate-land.mjs from Natural Earth's 1:110m land polygons,\n// which are in the public domain. Do not edit.\n\n/** Cells per row and rows in the grid: one cell per degree. */\nexport const LAND_COLUMNS = 360;\nexport const LAND_ROWS = 180;\n\n/**\n * Where the land is. One bit per cell, row 0 northernmost and column 0\n * westernmost, packed eight to a byte from the low bit and base64 encoded.\n */\nexport const LAND =\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg' +\n '/wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA/v8/AOD//z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAD+///D/////5//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAH/j/+//////8PAAAAgB0A' +\n 'AHgAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAAOD//wP8//////8BAACAfx4AAAAAAAAAAP4AAAAAAAAAAAAAAAAAAAAAAAA4' +\n '+I73//n///////8AAAAAfwAAAAAAAAAAAAAfAAAAAAAAAAAAAAAAAAAAAOAQACDwP4D///////8BAAAAPAQAAAAAAAAAAAAY' +\n 'AAAAAAAAAAAAAAAAAAAAADzAgPP5H8D//////38AAAAAAAAAAAAA4AEAAAD+DwAAAAAAAAAAAAAAAAAAAIC/g7PfBwAA/v//' +\n '//8AAAAAAAAAAADgBwAAwP//PwAA4B8AAAAAAAAAAAAAAAAMAAD8AgAA+P////8AAAAAAAAAAABwAAAA/P//AwAAAAAAAAAA' +\n 'AAAAAAAAAP8Ahvc5OwAA8P///38AAAAAAAAAAAAcAADg////34cHAAcAAAAAAAAAAAAAgN9/xzf8RwAA4P///z8AAAAAAAAA' +\n 'AAAPAB7g//////8fgB8AAAAAAQAAAAAAAO//B3f8/wsA4P///z8AAAAAAAAAAAAPAG////////8f2f8HAAAAAAD4HwAAAAD8' +\n 'H/D4/38A4P///xMAAAAAAPAHAAAAgN//////////////DwAAAAD+///h/4//H+/Bg/8BwP7//w8AAAAAwP8fAAAAj9//////' +\n '/////////88/AeD///////8DB8TPB/4AAP///wAAAAAA8P//AwDjf77/////////////////DwD////////////vh/kPwP//' +\n 'BwAAAAAA/P//H/P//5//////////////////34H8////////////Afw/gP//AQAAAAAA/v+fH////+//////////////////' +\n '/vD///////////9fAPwYgP8PAOA/AAAA/+N/8P//////////////////////QID4///////////Pw/0DAP8HAMAfAACA//F/' +\n '/v////////////////////8/AAP4///////////BBPAHAP4HAAAAAADg//z///////////////////////9/AID/////////' +\n '//8AQ8ABAPwBAAAAAAD8P/7///////////////////////9fAMD//////////z8AwA8AAPgBAAAAAAD+H/7/////////////' +\n '////////Of8BAID///z//////z8AwD8AAMABAAAAAAD+P/z///////////////////9/wH8AAAD8MwD//////x8AwH8MAAAA' +\n 'AAAAAAD+P2D///////////////////95cAAAAACAAwD8/////38AwP8eAAAAAAAAgAGcH/D//////////////////wEAOAAA' +\n 'AADADgDA/////38AgP8/AAAAAAAAwANAH/T//////////////////wAAfgAAAAAwAAAA//////8PgP8/AAAAAAAAwAFwD/7/' +\n '////////////////PwAAfwAAAAAGAAAA//////8/wP//AAAAAAAAwAOwA/7/////////////////DwCAPwAAAIAAAAAA/v//' +\n '////8///BwAAAAAAOAcgcP//////////////////HwAAHwAAAAAAAACA+P//////4///DwAAAAAAOA74////////////////' +\n '/////wUADwAAAAAAAAAA8P//////4///DwAAAAAAGD///////////////////////wUAAwAAAAAAAAAA8P//////7///FwAA' +\n 'AAAAAB///////////////////////wUAAgAAAAAAAAAA0P//////////CAAAAAAAgOH//////////////////////w0AAAAA' +\n 'AAAAAAAAYP////////8xLAAAAAAAAPz//////////////////////wwAAAAAAAAAAAAAAP7//////38HfgAAAAAAgP//////' +\n '/////////////////wQAAAAAAAAAAAAAAP3///////8HUAAAAAAAAP7/////////////////////fwQAAAAAAAAAAAAAAP//' +\n '//////+XAAAAAAAAAPz///93/P//////////////PwwAAAAAAAAAAAAAAP////////9/AAAAAAAAAPj//f9j/v//////////' +\n '////HwAAAAAAAAAAAAAAAP////////8MAAAAAAAAAPj/+P8B/P//////////////DwQAAAAAAAAAAAAAAP///////z8AAAAA' +\n 'AAAAcPzH8/8A8P//////////////Bx4AAAAAAAAAAAAAAP///////x8AAAAAAAAA+H+Aw/8AwP////////////9/AA8AAAAA' +\n 'AAAAAAAAAP///////x8AAAAAAAAA+D8Aj//w4f////////////8/AAAAAAAAAAAAAAAAAP///////wMAAAAAAAAA+B8wuE/+' +\n '/////////////98fAAMAAAAAAAAAAAAAAP///////wMAAAAAAAAA+A8wEMf//////////////0cOAAMAAAAAAAAAAAAAAP7/' +\n '/////wEAAAAAAAAA+A8AAI7//////////////wMOAAEAAAAAAAAAAAAAAP7//////wAAAAAAAAAA+AcABob/////////////' +\n '/zccwAEAAAAAAAAAAAAAAPz//////wAAAAAAAAAAQOB/AAQ2/////////////x8c8AEAAAAAAAAAAAAAAPj//////wAAAAAA' +\n 'AAAAQPx/AAAA/////////////w8Y/gEAAAAAAAAAAAAAAPD/////fwAAAAAAAAAA4P8/AAAA/////////////w+EGwAAAAAA' +\n 'AAAAAAAAAMD/////HwAAAAAAAAAA8P9/AACA/////////////x/AAwAAAAAAAAAAAAAAAID/////DwAAAAAAAAAA+P//BwaA' +\n '/////////////x/AAAAAAAAAAAAAAAAAAID9////BwAAAAAAAAAA/P//D3+E/////////////z9AAAAAAAAAAAAAAAAAAAD5' +\n '////BwAAAAAAAAAA/P//f////////////////x8AAAAAAAAAAAAAAAAAAADy/x8GBgAAAAAAAAAA/P/////v/8//////////' +\n '/z8AAAAAAAAAAAAAAAAAAAD0/w8ABgAAAAAAAAAA/v////+//4///////////z8AAAAAAAAAAAAAAAAAAADu/wcADgAAAAAA' +\n 'AACA//////8f/x/+/////////x8AAAAAAAAAAAAAAAAAAACI/wcALAAAAAAAAADA//////8//z/g/////////w8AAAAAAAAA' +\n 'AAAAAAAAAACQ/wcACAAAAAAAAADg//////9//r+A/////////wcAAAAAAAAAAAAAAAAAAAAQ/wMAAAAAAAAAAADg//////9/' +\n '/n8cgP///////ycAAAAAAAAAAAAAAAAAAAAA/gMAAAAAAAAAAADw////////+P9/AP///////xEAAAAAAAAAAAAAAAAAAAAA' +\n '/AMAHQAAAAAAAADw////////+P//APz/f///fxAAAAAAAAAAAAAAAAAAAAAA+AcAYAAAAAAAAAD4////////+f9/APz/B///' +\n 'BgAAAAAAAAAAAAAAAAAAAAAA/AccgAEAAAAAAADw////////8f9/AOD/B/5/AAAAAAAAAAAAAAAAAQAAAAAA+A8eADgAAAAA' +\n 'AADw////////4f8/AOD/Afw/BgAAAAAAAAAAAAAAAAAAAAAA8J8PAPwCAAAAAADw////////4/8fAOD/APw/AgAAAAAAAAAA' +\n 'AAAAAAAAAAAAgP8PAAAAAAAAAADw////////x/8HAOB/APx/ADAAAAAAAAAAAAAAAAAAAAAAAP4PAAAAAAAAAAD4////////' +\n 'h/8BAOA/APz/ADAAAAAAAAAAAAAAAAAAAAAAAID/AAAAAAAAAAD4////////j/8AAMAPAMD/ATAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAD/AQAAAAAAAAD4////////nx8AAMAPAMD/ATAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAD4////////vwcAAIAPAMD/' +\n 'AcAAAAAAAAAAAAAAAAAAAAAAAADgAQAAAAAAAAD4////////fwAAAIAPAMD8AQABAAAAAAAAAAAAAAAAAAAAAADAAAgAAAAA' +\n 'AADw////////f2AAAAAPAID4AUACAAAAAAAAAAAAAAAAAAAAAADAAe9zAAAAAADg/////////34AAAAPAEBwAAgAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAE+9/AAAAAADA/////////38AAAAXAEAgAAQCAAAAAAAAAAAAAAAAAAAAAAAAz///AAAAAACA////////' +\n '/z8AAAASAMAAAIACAAAAAAAAAAAAAAAAAAAAAAAAyP//AQAAAACA/////////z8AAAAwAIAAAEAHAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAgP//AwAAAAAA/v///////x8AAAAwAAADAAMBAAAAAAAAAAAAAAAAAAAAAAAAwP//fwAAAAAA/A/+/////x8AAAAAAAAH' +\n 'AAcAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAEAD8/////w8AAAAAADAGwAcAAAAAAAAAAAAAAAAAAAAAAAAAgP///wEA' +\n 'AAAAAADA/////wcAAAAAAGAG4AEAAAAAAAAAAAAAAAAAAAAAAAAA4P///wEAAAAAAADA/////wMAAAAAAMAM+AMAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAA4P///wMAAAAAAADg/////wEAAAAAAIAL/gMQAAAAAAAAAAAAAAAAAAAAAAAA8P///wMAAAAAAADg////' +\n 'fwAAAAAAAIAH/vMQAAAAAAAAAAAAAAAAAAAAAAAA8P///wcAAAAAAADg////PwAAAAAAAAAP/gMAAQAAAAAAAAAAAAAAAAAA' +\n 'AAAA+P///38AAAAAAADg////PwAAAAAAAAAO/DmAAwAAAAAAAAAAAAAAAAAAAAAA+P///38BAAAAAADA////HwAAAAAAAAA+' +\n '/DkA8gAAAAAAAAAAAAAAAAAAAAAA8P////8fAAAAAACA////DwAAAAAAAAA8wChE/gcAAAAAAAAAAAAAAAAAAAAA+P////8/' +\n 'AAAAAACA////BwAAAAAAAAA4AEAA8B8AAAAAAAAAAAAAAAAAAAAA+P//////AQAAAAAA////BwAAAAAAAAAwAEAAxD8NAAAA' +\n 'AAAAAAAAAAAAAAAA+P//////AQAAAAAA////BwAAAAAAAADAAQAAxP+AAAAAAAAAAAAAAAAAAAAA8P//////AQAAAAAA/v//' +\n 'BwAAAAAAAACAHwAAwH8ABAAAAAAAAAAAAAAAAAAA4P//////AQAAAAAA/v//BwAAAAAAAAAAQFYEAMcAAAAAAAAAAAAAAAAA' +\n 'AAAA4P//////AAAAAAAA/v//DwAAAAAAAAAAAAgBAIABMAAAAAAAAAAAAAAAAAAAwP//////AAAAAAAA/v//DwAAAAAAAAAA' +\n 'AAAAAAAEAAAAAAAAAAAAAAAAAAAAgP////9/AAAAAAAA/P//DwAAAAAAAAAAAAAAAQQAAAAAAAAAAAAAAAAAAAAAgP////8/' +\n 'AAAAAAAA/v//HyAAAAAAAAAAAACAHwQAAAAAAAAAAAAAAAAAAAAAAP////8fAAAAAAAA/v//HyAAAAAAAAAAAADADwwAAAAA' +\n 'AAAAAAAAAAAAAAAAAP////8fAAAAAAAA////HzAAAAAAAAAAAADsDxwAAAAAAAAAAAAAAAAAAAAAAP7///8fAAAAAAAA////' +\n 'DzgAAAAAAAAAAAD/DxwAAAAAAAAAAAAAAAAAAAAAAPj///8fAAAAAAAA////Dz8AAAAAAAAAAAD/Pz4AAAiAAAAAAAAAAAAA' +\n 'AAAAAOD///8fAAAAAAAA////Ax8AAAAAAAAAAMD//z4AAABAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA////AB8AAAAAAAAA' +\n 'AMD//z8AAAAAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA/v9/AB8AAAAAAAAAAOD//38AAAAAAAAAAAAAAAAAAAAAAMD///8P' +\n 'AAAAAAAA/v9/AB8AAAAAAAAAAPz///8BAAEAAAAAAAAAAAAAAAAAAMD///8HAAAAAAAA/P9/gA8AAAAAAAAAgP////8BAAIA' +\n 'AAAAAAAAAAAAAAAAAMD///8DAAAAAAAA/P//gA8AAAAAAAAAwP////8DAAAAAAAAAAAAAAAAAAAAAMD//38AAAAAAAAA/P9/' +\n 'AA8AAAAAAAAAwP////8HAAAAAAAAAAAAAAAAAAAAAOD//x8AAAAAAAAA+P9/AAcAAAAAAAAA4P////8PAAAAAAAAAAAAAAAA' +\n 'AAAAAOD//w8AAAAAAAAA+P8fAAIAAAAAAAAAwP////8fAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA+P8fAAAAAAAAAAAA' +\n '4P////8fAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA+P8fAAAAAAAAAAAAwP////8fAAAAAAAAAAAAAAAAAAAAAOD//wcA' +\n 'AAAAAAAA8P8PAAAAAAAAAAAAgP////8/AAAAAAAAAAAAAAAAAAAAAOD//wMAAAAAAAAA4P8HAAAAAAAAAAAAgP////8fAAAA' +\n 'AAAAAAAAAAAAAAAAAPD//wMAAAAAAAAA4P8HAAAAAAAAAAAAgP////8fAAAAAAAAAAAAAAAAAAAAAPD//wEAAAAAAAAAwP8D' +\n 'AAAAAAAAAAAAAP////8fAAAAAAAAAAAAAAAAAAAAAOD//wAAAAAAAAAAwP8BAAAAAAAAAAAAAP8D/P8PAAAAAAAAAAAAAAAA' +\n 'AAAAAPD/fwAAAAAAAAAAwH8AAAAAAAAAAAAAgP8A2P8HAAAAAAAAAAAAAAAAAAAAAPD/OwAAAAAAAAAAgAEAAAAAAAAAAAAA' +\n 'AAcA6P8HAAAAAAAAAAAAAAAAAAAAAPj/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP8DAAACAAAAAAAAAAAAAAAAAPj/BwAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8DAAAEAAAAAAAAAAAAAAAAAPz/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8DAAAI' +\n 'AAAAAAAAAAAAAAAAAPj/AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGgAAAA4AAAAAAAAAAAAAAAAAPg/AAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAPw/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAA' +\n 'AAAAAPwHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAALAAAAAAAAAAAAAAAAAPwfAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAOAAAIADAAAAAAAAAAAAAAAAAPgHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAMABAAAAAAAAAAAAAAAAAPwHAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAAAP4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgA' +\n 'AAAAAAAAAAAAAAAAAP4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAAAAAP4DAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAP8AAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4AAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAH6AAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAPADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAIAfAAAAAAAe' +\n 'eADADwAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAPD/AADA/////////z8AAAAAAAAAAAAAAAAAAAAAAAAHAAAA' +\n 'AAAAAAAAAAAAgP///wH4//////////8DAAAAAAAAAAAAAAAAAAAAADAfAAAAAAAAAAAAAADw8////wP+////////////AwAA' +\n 'AAAAAAAAAAAAAAAAAHA/AAAAAAAAAAA8//P//////+D/////////////HwAAAAAAAAAAAAAAAAAAAP5+AAAAAAAA5v//////' +\n '//////D//////////////38AAAAAAAAAAAAAAAAAAAB+AAAAAACA/////////////////////////////38AAAAAAAAAYAYA' +\n '4ON/OPh/AAAAAADw/////////////////////////////x8AAAAAAADwv/MXgP////8fAAAAAADw////////////////////' +\n '/////////wMAAAAAAPz///////////8HAAAAAAD+/////////////////////////////wAAAAAAAPz//////////z8AAAAA' +\n 'AP///////////////////////////////wAAAADA/////////////wEAAAAA8P///////////////////////////////wAA' +\n 'AACH////////////PwAAAPgA/////////////////////////////////wcAAAAcgP//////////fwAAAP4BwP//////////' +\n '////////////////////HwAAAAAAAP7//////////wf8wD8AwP//////////////////////////////DwAAAAAA/v//////' +\n '//////8HAAD4////////////////////////////////HwAAAAAA+P//////////////4f//////////////////////////' +\n '/////////wAAAAAA/P///////////////////////////////////////////////////x8A3x8AAP7/////////////////' +\n '//////////////////////////////////8/AAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +\n 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';\n"
+ }
+ ]
+}
diff --git a/apps/docs/public/r/web/range-meter.json b/apps/docs/public/r/web/range-meter.json
new file mode 100644
index 00000000..5c0914a0
--- /dev/null
+++ b/apps/docs/public/r/web/range-meter.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "range-meter",
+ "type": "registry:component",
+ "title": "Range Meter",
+ "description": "A reading on a ruler of ticks that rises where the value is, with the number above it.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/range-meter.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/range-meter.tsx",
+ "content": "'use client';\n\n/**\n * RangeMeter — where a reading sits between its lowest and its highest, on a\n * ruler of ticks that rises under the value.\n *\n * ```tsx\n * \n * ```\n *\n * ## The ruler swells where the reading is\n *\n * The track is a row of ticks. The ones up to the reading are lit, and the\n * ticks round the reading stand taller, falling away on either side, so the\n * value is a rise in the ruler and not a knob sitting on a line. Dragging\n * moves the rise along, and the ticks it leaves settle back behind it.\n *\n * ## The number rides above\n *\n * The value is written in a tag over the rise, so the place on the ruler and\n * the figure are one thing to read. The tag is hung from the point it marks\n * in proportion to how far along that point is: from its left edge at the low\n * end, its middle half way, its right edge at the high end. It is always over\n * the reading and never outside the ruler.\n *\n * ## A reading or a control\n *\n * With `onValueChange` it is a slider: drag it, or use the arrow keys. Without\n * it, the same drawing is a meter that shows a value and cannot be moved.\n * Either way a screen reader is given the value and the range it is in.\n *\n * ## The colour is the theme's\n *\n * `color` names one of the theme's roles. Changing it as the value crosses a\n * threshold is how a meter says the reading has gone from fine to not.\n */\nimport {\n useCallback,\n useRef,\n type ComponentProps,\n type KeyboardEvent,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\n\n/** Which of the theme's roles the lit ticks and the tag are drawn in. */\nexport type RangeMeterColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';\n\nconst rangeMeterVariants = tv({\n slots: {\n // Room above the ruler for the tag, so the tag is inside the control's\n // own box and cannot be clipped by whatever the meter sits in.\n root: 'relative w-full touch-none select-none rounded-lg pt-9 outline-none rtl:[--pu-dir:-1] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background',\n ruler: 'relative flex h-10 items-end justify-between',\n tick: 'w-[3px] rounded-full transition-[height,background-color,opacity,box-shadow] duration-200 ease-out motion-reduce:transition-none',\n marker:\n 'pointer-events-none absolute -bottom-2.5 size-1.5 -translate-x-1/2 rotate-45 rounded-[1px] bg-foreground rtl:translate-x-1/2',\n tag: 'pointer-events-none absolute top-0 flex h-7 items-center gap-1 whitespace-nowrap rounded-full bg-[var(--pu-color)] px-2.5 text-xs font-semibold tabular-nums text-[var(--pu-on-color)] shadow-[0_6px_18px_-4px_color-mix(in_oklab,var(--pu-color)_70%,transparent)] transition-[background-color,box-shadow,scale] duration-200 motion-reduce:transition-none [&_svg]:size-3.5 [&_svg]:shrink-0',\n },\n variants: {\n /*\n * A role and what is written on it. `primary` has a partner in every\n * theme. The status roles are mid-tones, light enough that the theme\n * writes on them in near-black whether the page is light or dark.\n */\n color: {\n primary: { root: '[--pu-color:var(--primary)] [--pu-on-color:var(--primary-foreground)]' },\n info: { root: '[--pu-color:var(--info)] [--pu-on-color:var(--color-neutral-900)]' },\n success: { root: '[--pu-color:var(--success)] [--pu-on-color:var(--color-neutral-900)]' },\n warning: { root: '[--pu-color:var(--warning)] [--pu-on-color:var(--color-neutral-900)]' },\n destructive: {\n root: '[--pu-color:var(--destructive)] [--pu-on-color:var(--color-neutral-900)]',\n },\n },\n lit: {\n true: { tick: 'bg-[var(--pu-color)]' },\n false: { tick: 'bg-muted-foreground/25' },\n },\n // The tick the reading is on, which is the top of the rise.\n crest: {\n true: {\n tick: 'shadow-[0_0_10px_color-mix(in_oklab,var(--pu-color)_70%,transparent)]',\n },\n },\n // The marker hangs under the ruler, and the control makes room for it.\n marked: {\n true: { root: 'pb-4' },\n },\n interactive: {\n true: { root: 'cursor-grab active:cursor-grabbing active:[&_[data-tag]]:scale-110' },\n },\n },\n});\n\nexport interface RangeMeterProps extends Omit, 'onChange' | 'color'> {\n /** The low end of the ruler. */\n min?: number;\n /** The high end of the ruler. */\n max?: number;\n /** The reading: where the ruler rises and the number written above it. */\n value: number;\n /**\n * Called with the new value as the ruler is dragged or moved with the\n * keyboard. Passing this makes it a slider; without it, it is a meter.\n */\n onValueChange?: (value: number) => void;\n /** How much one arrow key press moves the value, and what a drag snaps to. */\n step?: number;\n /** A value to mark under the ruler, such as a target. */\n marker?: number;\n /** How many ticks the ruler is drawn with. */\n ticks?: number;\n /** Which of the theme's roles the lit ticks and the tag are drawn in. */\n color?: RangeMeterColor;\n /** Writes the number in the tag. Defaults to the value as it is. */\n formatValue?: (value: number) => ReactNode;\n /** What a screen reader calls it. */\n label?: string;\n /** What a screen reader says for the value: \"240 milliseconds\". */\n valueText?: string;\n /** Drawn in the tag before the number: an icon. */\n children?: ReactNode;\n}\n\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n\n/** How many ticks either side of the reading the rise spreads over. */\nconst SPREAD = 2.6;\n/** A resting tick's height, as a share of the ruler's. */\nconst REST = 0.34;\n\nexport function RangeMeter({\n min = 0,\n max = 100,\n value,\n onValueChange,\n step = 1,\n marker,\n ticks = 41,\n color = 'primary',\n formatValue,\n label = 'Reading',\n valueText,\n className,\n children,\n ...props\n}: RangeMeterProps) {\n const interactive = onValueChange !== undefined;\n const slots = rangeMeterVariants({ color, interactive, marked: marker !== undefined });\n const ruler = useRef(null);\n const dragging = useRef(false);\n\n const count = Math.max(2, Math.round(ticks));\n const span = max - min || 1;\n const share = clamp((value - min) / span, 0, 1);\n // Where the reading falls among the ticks, as a position between two of\n // them, so the rise slides along the ruler and does not hop tick to tick.\n const at = share * (count - 1);\n const crest = Math.round(at);\n\n // Rounded to the step's own precision, so 0.1 steps do not come back as\n // 0.30000000000000004.\n const places = (String(step).split('.')[1] ?? '').length;\n const settle = useCallback(\n (raw: number) => clamp(Number((Math.round(raw / step) * step).toFixed(places)), min, max),\n [step, places, min, max]\n );\n\n const fromPointer = useCallback(\n (clientX: number) => {\n const node = ruler.current;\n const box = node?.getBoundingClientRect();\n if (!node || !box || box.width === 0) return;\n const rtl = getComputedStyle(node).direction === 'rtl';\n const along = rtl ? box.right - clientX : clientX - box.left;\n onValueChange?.(settle(min + clamp(along / box.width, 0, 1) * span));\n },\n [min, span, settle, onValueChange]\n );\n\n const onPointerDown = (event: ReactPointerEvent) => {\n if (!interactive || event.button !== 0) return;\n event.currentTarget.setPointerCapture(event.pointerId);\n dragging.current = true;\n fromPointer(event.clientX);\n };\n\n const onPointerMove = (event: ReactPointerEvent) => {\n if (dragging.current) fromPointer(event.clientX);\n };\n\n const endDrag = () => {\n dragging.current = false;\n };\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (!interactive) return;\n const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';\n const page = Math.max(step, Math.round(span / 10 / step) * step);\n const moves: Record = {\n ArrowLeft: value + (rtl ? step : -step),\n ArrowRight: value + (rtl ? -step : step),\n ArrowDown: value - step,\n ArrowUp: value + step,\n PageDown: value - page,\n PageUp: value + page,\n Home: min,\n End: max,\n };\n const next = moves[event.key];\n if (next === undefined) return;\n event.preventDefault();\n onValueChange(clamp(Number(next.toFixed(places)), min, max));\n };\n\n return (\n \n
\n {children}\n {formatValue ? formatValue(value) : value}\n \n\n
\n {Array.from({ length: count }, (_, index) => {\n const distance = (index - at) / SPREAD;\n // A bell: full height at the reading, resting height a few ticks out.\n const rise = Math.exp(-distance * distance);\n const lit = index <= at + 0.001;\n const part = rangeMeterVariants({ lit, crest: index === crest });\n return (\n \n );\n })}\n {marker !== undefined ? (\n \n ) : null}\n
\n
\n );\n}\nRangeMeter.displayName = 'RangeMeter';\n"
+ }
+ ]
+}
diff --git a/apps/docs/public/r/web/registry.json b/apps/docs/public/r/web/registry.json
new file mode 100644
index 00000000..56173b7a
--- /dev/null
+++ b/apps/docs/public/r/web/registry.json
@@ -0,0 +1,302 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "name": "panelui",
+ "homepage": "https://panelui.dev/docs/web",
+ "items": [
+ {
+ "name": "bar-picker",
+ "type": "registry:component",
+ "title": "Bar Picker",
+ "description": "A short run of values as columns of lights, one column picked.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/bar-picker.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/bar-picker.tsx"
+ }
+ ]
+ },
+ {
+ "name": "command-dock",
+ "type": "registry:component",
+ "title": "Command Dock",
+ "description": "A small button that opens into a field, with the answer arriving under it a row at a time.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "files": [
+ {
+ "path": "components/panelui/command-dock.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/command-dock.tsx"
+ }
+ ]
+ },
+ {
+ "name": "day-grid",
+ "type": "registry:component",
+ "title": "Day Grid",
+ "description": "A run of days as a field of dots, each sized by how much happened on it.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/day-grid.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/day-grid.tsx"
+ }
+ ]
+ },
+ {
+ "name": "feedback",
+ "type": "registry:component",
+ "title": "Feedback",
+ "description": "A dialog with a well to write in and two actions in the band around it.",
+ "dependencies": [
+ "@base-ui/react",
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "files": [
+ {
+ "path": "components/panelui/feedback.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/feedback.tsx"
+ }
+ ]
+ },
+ {
+ "name": "flow",
+ "type": "registry:component",
+ "title": "Flow",
+ "description": "A canvas of nodes joined by edges, that you pan, zoom and rearrange.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/flow/index.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/flow/index.tsx"
+ },
+ {
+ "path": "components/panelui/flow/flow-paths.ts",
+ "type": "registry:component",
+ "target": "@components/panelui/flow/flow-paths.ts"
+ }
+ ]
+ },
+ {
+ "name": "kpi",
+ "type": "registry:component",
+ "title": "KPI",
+ "description": "One number and what it is doing: its change, its shape over time, its progress to a target.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/kpi.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/kpi.tsx"
+ }
+ ]
+ },
+ {
+ "name": "lane-board",
+ "type": "registry:component",
+ "title": "Lane Board",
+ "description": "Lanes of cards that can be dragged within a lane and from one lane to another.",
+ "dependencies": [
+ "@base-ui/react",
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/lane-board.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/lane-board.tsx"
+ }
+ ]
+ },
+ {
+ "name": "planet",
+ "type": "registry:component",
+ "title": "Planet",
+ "description": "The world as dots on a globe you can turn, with markers pinned to it.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/planet/index.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/planet/index.tsx"
+ },
+ {
+ "path": "components/panelui/planet/land.ts",
+ "type": "registry:component",
+ "target": "@components/panelui/planet/land.ts"
+ }
+ ]
+ },
+ {
+ "name": "range-meter",
+ "type": "registry:component",
+ "title": "Range Meter",
+ "description": "A reading on a ruler of ticks that rises where the value is, with the number above it.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/range-meter.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/range-meter.tsx"
+ }
+ ]
+ },
+ {
+ "name": "rolling-number",
+ "type": "registry:component",
+ "title": "Rolling Number",
+ "description": "A number whose digits roll to their new values.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "files": [
+ {
+ "path": "components/panelui/rolling-number.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/rolling-number.tsx"
+ }
+ ]
+ },
+ {
+ "name": "tilt-card",
+ "type": "registry:component",
+ "title": "Tilt Card",
+ "description": "A card that leans towards the pointer, with layers inside it that move by their depth.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/tilt-card.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/tilt-card.tsx"
+ }
+ ]
+ }
+ ]
+}
diff --git a/apps/docs/public/r/web/rolling-number.json b/apps/docs/public/r/web/rolling-number.json
new file mode 100644
index 00000000..e742877b
--- /dev/null
+++ b/apps/docs/public/r/web/rolling-number.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "rolling-number",
+ "type": "registry:component",
+ "title": "Rolling Number",
+ "description": "A number whose digits roll to their new values.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "files": [
+ {
+ "path": "components/panelui/rolling-number.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/rolling-number.tsx",
+ "content": "'use client';\n\n/**\n * RollingNumber — a number whose digits roll to their new values.\n *\n * ```tsx\n * \n * \n * ```\n *\n * Every digit is a column of ten, and the column is moved by a share of its\n * own height. Nothing is measured: one hidden `0` in flow gives the column the\n * width and the line box the font really has, and the ten digits stacked over\n * it are each exactly that tall, so `translateY(-30%)` is the 3 whatever the\n * type size turns out to be.\n *\n * That hidden `0` is also why the number sits on the text's baseline. A box\n * that clips its overflow takes its bottom edge as its baseline, which would\n * lift every digit above the words beside it. Here the clipping is done by an\n * absolutely positioned window inside the column, and the column's baseline\n * is still the `0`'s.\n *\n * The columns are keyed from the decimal point outwards. Going from 99 to 100\n * adds a hundreds column rather than re-labelling the two that were there, so\n * the nines roll to zeroes instead of being replaced by them.\n */\nimport { useMemo, type ComponentProps } from 'react';\nimport { tv } from 'tailwind-variants';\n\nconst rollingNumberVariants = tv({\n slots: {\n // `tabular-nums` so a 1 is as wide as an 8 and the number does not change\n // width, and shove whatever is beside it, while it rolls.\n root: 'inline-flex items-baseline tabular-nums',\n column: 'relative inline-block',\n window: 'absolute inset-0 overflow-hidden',\n strip:\n 'absolute inset-x-0 top-0 block h-[1000%] transition-transform ease-[cubic-bezier(0.2,0,0,1)] will-change-transform motion-reduce:transition-none',\n digit: 'block h-[10%] text-center',\n },\n});\n\nconst DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] as const;\n\nexport interface RollingNumberProps extends Omit, 'children'> {\n /** The number to show. Each digit rolls to its new value on its own. */\n value: number;\n /**\n * How the number is written: currency, percent, compact notation, how many\n * decimals. Passed to `Intl.NumberFormat`.\n */\n format?: Intl.NumberFormatOptions;\n /**\n * The locale the number is written in. Defaults to `en-US` rather than the\n * browser's, so a page rendered on a server writes the same thing the\n * browser then does.\n */\n locales?: Intl.LocalesArgument;\n /** How long a digit takes to roll, in milliseconds. */\n duration?: number;\n}\n\ninterface Piece {\n key: string;\n /** A digit to roll to, or `null` for a character that is just written. */\n digit: number | null;\n text: string;\n}\n\n/** Splits a formatted number into rolling digits and the characters around them. */\nfunction piecesOf(parts: Intl.NumberFormatPart[]): Piece[] {\n // Whole digits are counted from the decimal point, so the units column is\n // the same column whatever is in front of it.\n let whole = parts.reduce(\n (count, part) => (part.type === 'integer' ? count + part.value.length : count),\n 0\n );\n let fraction = 0;\n const pieces: Piece[] = [];\n\n parts.forEach((part, index) => {\n if (part.type === 'integer' || part.type === 'fraction') {\n for (const character of part.value) {\n const digit = character >= '0' && character <= '9' ? Number(character) : null;\n const key = part.type === 'integer' ? `i${(whole -= 1)}` : `f${(fraction += 1)}`;\n pieces.push({ key, digit, text: character });\n }\n return;\n }\n // A separator belongs to the digits on its right; anything else is keyed\n // by what it is, so a currency sign survives the number growing a digit.\n const key = part.type === 'group' ? `g${whole}` : `${part.type}${index}`;\n pieces.push({ key, digit: null, text: part.value });\n });\n\n return pieces;\n}\n\nexport function RollingNumber({\n value,\n format,\n locales = 'en-US',\n duration = 600,\n className,\n ...props\n}: RollingNumberProps) {\n const slots = rollingNumberVariants();\n\n // The options object is usually written inline, so it is new on every\n // render. Its contents are what the formatter depends on.\n const formatKey = JSON.stringify(format ?? null);\n const formatter = useMemo(\n () => new Intl.NumberFormat(locales, format),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [locales, formatKey]\n );\n\n const { label, pieces } = useMemo(() => {\n const parts = formatter.formatToParts(value);\n return { label: parts.map((part) => part.value).join(''), pieces: piecesOf(parts) };\n }, [formatter, value]);\n\n return (\n \n {/* What a screen reader gets: the number, once, as it is written. */}\n {label} \n {pieces.map((piece) =>\n piece.digit === null ? (\n \n {piece.text}\n \n ) : (\n \n 0 \n \n \n {DIGITS.map((digit) => (\n \n {digit}\n \n ))}\n \n \n \n )\n )}\n \n );\n}\nRollingNumber.displayName = 'RollingNumber';\n"
+ }
+ ]
+}
diff --git a/apps/docs/public/r/web/tilt-card.json b/apps/docs/public/r/web/tilt-card.json
new file mode 100644
index 00000000..150a0b4b
--- /dev/null
+++ b/apps/docs/public/r/web/tilt-card.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "tilt-card",
+ "type": "registry:component",
+ "title": "Tilt Card",
+ "description": "A card that leans towards the pointer, with layers inside it that move by their depth.",
+ "dependencies": [
+ "tailwind-variants"
+ ],
+ "registryDependencies": [],
+ "cssVars": {
+ "light": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ },
+ "dark": {
+ "success": "oklch(0.696 0.17 162.48)",
+ "warning": "oklch(0.769 0.188 70.08)",
+ "info": "oklch(0.623 0.214 259.815)"
+ }
+ },
+ "files": [
+ {
+ "path": "components/panelui/tilt-card.tsx",
+ "type": "registry:component",
+ "target": "@components/panelui/tilt-card.tsx",
+ "content": "'use client';\n\n/**\n * TiltCard — a card that leans towards the pointer, with layers inside it\n * that move by how near they are.\n *\n * ```tsx\n * \n * …a figure… \n * …its caption… \n * \n * ```\n *\n * ## The card turns on the pointer\n *\n * The card is tipped a few degrees towards wherever the pointer is on it, as\n * a card held in the hand is tipped towards the eye, and a patch of light\n * sits under the pointer. When the pointer leaves, it settles back level.\n *\n * ## Layers give it depth\n *\n * A `TiltCard.Layer` is lifted off the card by its `depth`. As the card tips,\n * a layer further out moves further, which is the whole of what makes a flat\n * drawing read as a thing with a front and a back. Without layers the card\n * still tips; with them there is something to look round.\n *\n * ## It is decoration\n *\n * Nothing is said by the tilt, so nothing is lost without it: it does not\n * follow a finger, and it holds still for anyone who has asked their system\n * for reduced motion. Whatever the card says is in its contents.\n */\nimport {\n useRef,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n} from 'react';\nimport { tv } from 'tailwind-variants';\n\n/** Which of the theme's roles the light under the pointer is drawn in. */\nexport type TiltCardColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';\n\nconst tiltCardVariants = tv({\n slots: {\n // The eye's distance from the card. Nearer and the lean would be a lurch.\n root: 'group/tilt relative [perspective:900px]',\n /*\n * The lean is two variables the pointer sets. They move at once while the\n * pointer is on the card, and ease back when it has gone, so the card\n * follows a hand exactly and then settles on its own.\n */\n card: 'relative overflow-hidden rounded-3xl border bg-card text-card-foreground shadow-sm transition-[transform,box-shadow,border-color] duration-500 ease-out [transform-style:preserve-3d] [transform:rotateX(var(--pu-rx,0deg))_rotateY(var(--pu-ry,0deg))] group-data-[tilting]/tilt:shadow-xl group-data-[tilting]/tilt:duration-100 motion-reduce:transition-none motion-reduce:[transform:none]',\n // The light is the card's colour, soft and wide, centred on the pointer.\n glare:\n 'pointer-events-none absolute inset-0 rounded-[inherit] bg-[radial-gradient(18rem_circle_at_var(--pu-gx,50%)_var(--pu-gy,50%),color-mix(in_oklab,var(--pu-color)_16%,transparent),transparent_70%)] opacity-0 transition-opacity duration-300 group-data-[tilting]/tilt:opacity-100',\n layer:\n 'transition-transform duration-500 ease-out [transform:translate3d(calc(var(--pu-px,0)*var(--pu-depth)*1px),calc(var(--pu-py,0)*var(--pu-depth)*1px),calc(var(--pu-depth)*1px))] group-data-[tilting]/tilt:duration-100 motion-reduce:[transform:none]',\n },\n variants: {\n color: {\n primary: { card: '[--pu-color:var(--primary)]' },\n info: { card: '[--pu-color:var(--info)]' },\n success: { card: '[--pu-color:var(--success)]' },\n warning: { card: '[--pu-color:var(--warning)]' },\n destructive: { card: '[--pu-color:var(--destructive)]' },\n },\n },\n defaultVariants: { color: 'primary' },\n});\n\nexport interface TiltCardProps extends Omit, 'color'> {\n /** The furthest the card leans, in degrees. */\n tilt?: number;\n /** Which of the theme's roles the light under the pointer is drawn in. */\n color?: TiltCardColor;\n /** Set to `false` for a card with no light under the pointer. */\n glare?: boolean;\n /** Extra classes for the card itself. `className` goes on the box round it. */\n cardClassName?: string;\n}\n\nfunction TiltCardRoot({\n tilt = 8,\n color = 'primary',\n glare = true,\n className,\n cardClassName,\n children,\n onPointerMove,\n onPointerLeave,\n ...props\n}: TiltCardProps) {\n const slots = tiltCardVariants({ color });\n const root = useRef(null);\n const frame = useRef(0);\n\n const lean = (event: ReactPointerEvent) => {\n onPointerMove?.(event);\n // A finger has no hover to end the lean, and would cover it anyway.\n if (event.pointerType === 'touch') return;\n if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;\n const node = root.current;\n if (!node) return;\n const { clientX, clientY } = event;\n cancelAnimationFrame(frame.current);\n frame.current = requestAnimationFrame(() => {\n const box = node.getBoundingClientRect();\n // Where the pointer is on the card, from -1 at one edge to 1 at the other.\n const x = ((clientX - box.left) / box.width) * 2 - 1;\n const y = ((clientY - box.top) / box.height) * 2 - 1;\n node.dataset.tilting = '';\n // Towards the pointer: the near edge comes forward.\n node.style.setProperty('--pu-rx', `${(-y * tilt).toFixed(2)}deg`);\n node.style.setProperty('--pu-ry', `${(x * tilt).toFixed(2)}deg`);\n node.style.setProperty('--pu-px', x.toFixed(3));\n node.style.setProperty('--pu-py', y.toFixed(3));\n node.style.setProperty('--pu-gx', `${(((x + 1) / 2) * 100).toFixed(1)}%`);\n node.style.setProperty('--pu-gy', `${(((y + 1) / 2) * 100).toFixed(1)}%`);\n });\n };\n\n const settle = (event: ReactPointerEvent) => {\n onPointerLeave?.(event);\n cancelAnimationFrame(frame.current);\n const node = root.current;\n if (!node) return;\n delete node.dataset.tilting;\n for (const name of ['--pu-rx', '--pu-ry', '--pu-px', '--pu-py']) node.style.removeProperty(name);\n };\n\n return (\n \n
\n {children}\n {glare ? : null}\n
\n
\n );\n}\nTiltCardRoot.displayName = 'TiltCard';\n\nexport interface TiltCardLayerProps extends ComponentProps<'div'> {\n /** How far off the card it is lifted, in pixels. Further out moves further as the card leans. */\n depth?: number;\n}\n\n/** Something lifted off the card, which moves against it as the card leans. */\nfunction TiltCardLayer({ depth = 16, className, style, ...props }: TiltCardLayerProps) {\n return (\n
\n );\n}\nTiltCardLayer.displayName = 'TiltCard.Layer';\n\nexport const TiltCard = Object.assign(TiltCardRoot, {\n Layer: TiltCardLayer,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n module. */\nexport { TiltCardLayer };\n"
+ }
+ ]
+}
diff --git a/apps/docs/public/skills/panelui-web/SKILL.md b/apps/docs/public/skills/panelui-web/SKILL.md
new file mode 100644
index 00000000..1a2401b1
--- /dev/null
+++ b/apps/docs/public/skills/panelui-web/SKILL.md
@@ -0,0 +1,48 @@
+---
+name: panelui-web
+description: Build React web UIs with PanelUI's web collection — adding its components from the shadcn registry or from npm, colouring them from the theme, and composing them into framed panels. Applies when working in a React project that has shadcn/ui set up with a @panelui registry or has panelui-web installed, when the user mentions PanelUI on the web, or when they ask for a dotted globe, a KPI card, a kanban board, a node graph, an activity grid or a feedback dialog in React.
+license: MIT
+compatibility: React 19 with Tailwind CSS v4 and shadcn/ui tokens. Not for React Native — the app library is a separate skill, panelui.
+metadata:
+ author: Khalid Abdi
+ version: 0.1.0
+allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx shadcn@latest *)
+---
+
+# PanelUI for the web
+
+Components for React, built on shadcn/ui. They are styled with Tailwind CSS v4 on the theme tokens
+a shadcn project already has, so they take the project's colours, radii and dark mode without any
+setup of their own.
+
+This is the web collection. PanelUI's React Native library is a different package with a different
+skill; nothing here runs in React Native.
+
+## Before writing any UI
+
+1. **Read [components.md](components.md)** to see what exists. Use a component from it before
+ writing a custom one.
+2. **Read the component's own file** in `components/` before using it. It has the import line,
+ every part and every prop, read from the source. Do not guess a prop.
+3. **Check how the project installs**: a `components.json` with a `@panelui` registry means
+ components are copied in with the shadcn CLI; `panelui-web` in `package.json` means they are
+ imported from the package. See [setup.md](setup.md).
+
+## Rules
+
+- **Colour is a role, never a value.** Every component that has a colour takes
+ `color="primary" | "info" | "success" | "warning" | "destructive"`. Do not pass hex values or
+ Tailwind palette classes for a component's own colour. See [theming.md](theming.md).
+- **Pick the role by meaning.** `success` for good, `warning` for attention, `destructive` for
+ bad, `info` for neutral emphasis, `primary` otherwise. Do not give every component on a screen
+ the same colour.
+- **Give data its own text.** Labels, counts and names in a component come from the project's
+ data. Do not reuse the sample text from these files.
+- **Compound parts and Server Components.** In a file with `"use client"`, write parts with a dot:
+ `Planet.Globe`. A Server Component cannot read a property off a client module, so there every
+ part is imported by name: `PlanetGlobe`. `Kpi` is not a client module and can be dotted
+ anywhere.
+- **Lead with a frame.** A component shown as a panel sits in a frame: a muted band with a title,
+ round an inner bordered card. See the examples in each component's file.
+- **Label what a screen reader needs.** Components take `label`, and where a value is shown,
+ `valueText`. Pass them: the defaults are generic.
diff --git a/apps/docs/public/skills/panelui-web/components.md b/apps/docs/public/skills/panelui-web/components.md
new file mode 100644
index 00000000..2fa296bf
--- /dev/null
+++ b/apps/docs/public/skills/panelui-web/components.md
@@ -0,0 +1,26 @@
+# Components
+
+Generated from the collection's source. Read a component's own file before using it: the props in it are the real ones.
+
+## Overlays
+
+- [Command Dock](components/command-dock.md) — A small button that opens into a field, with the answer arriving under it a row at a time.
+
+## Layout and structure
+
+- [Lane Board](components/lane-board.md) — Lanes of cards that can be dragged within a lane and from one lane to another.
+- [Tilt Card](components/tilt-card.md) — A card that leans towards the pointer, with layers inside it that move by their depth.
+
+## Data
+
+- [Bar Picker](components/bar-picker.md) — A short run of values as columns of lights, one column picked.
+- [Day Grid](components/day-grid.md) — A run of days as a field of dots, each sized by how much happened on it.
+- [Flow](components/flow.md) — A canvas of nodes joined by edges, that you pan, zoom and rearrange.
+- [KPI](components/kpi.md) — One number and what it is doing: its change, its shape over time, its progress to a target.
+- [Planet](components/planet.md) — The world as dots on a globe you can turn, with markers pinned to it.
+- [Range Meter](components/range-meter.md) — A reading on a ruler of ticks that rises where the value is, with the number above it.
+- [Rolling Number](components/rolling-number.md) — A number whose digits roll to their new values.
+
+## Feedback and status
+
+- [Feedback](components/feedback.md) — A dialog with a well to write in and two actions in the band around it.
diff --git a/apps/docs/public/skills/panelui-web/components/bar-picker.md b/apps/docs/public/skills/panelui-web/components/bar-picker.md
new file mode 100644
index 00000000..947fce6c
--- /dev/null
+++ b/apps/docs/public/skills/panelui-web/components/bar-picker.md
@@ -0,0 +1,134 @@
+# Bar Picker
+
+A short run of values as columns of lights, one column picked.
+
+```tsx
+import { BarPicker, type BarPickerBar } from "@/components/panelui/bar-picker"
+```
+
+From npm the path is `panelui-web/bar-picker`. Install with `npx shadcn@latest add @panelui/bar-picker`.
+
+## When to use it
+
+Use it for a handful of values where one is in focus: a week with one day picked, six sprints with the latest picked. Each value is a column of lights, lit from the bottom up to the value. Pressing a column picks it, and you show that column's figure beside the row.
+
+It behaves as a row of radio buttons, so exactly one column is always picked.
+
+A column of eight lights shows a value to the nearest eighth. For values someone has to read precisely, for more than about twelve of them, or for more than one series, use a chart with an axis.
+
+## Usage
+
+```tsx
+const week: BarPickerBar[] = [
+ { label: 'Mon', name: 'Monday', value: 9 },
+ { label: 'Tue', name: 'Tuesday', value: 14 },
+ { label: 'Wed', name: 'Wednesday', value: 11 },
+]
+
+
+```
+
+## Props
+
+Every part also takes the props of the element it renders, and a `className`.
+
+#### `BarPickerProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `data` | `BarPickerBar[]` | **required** | The columns, left to right. |
+| `max` | `number` | — | The value a fully lit column stands for. Defaults to the largest in `data`. |
+| `value` | `number` | — | The index of the picked column, when the caller holds it. |
+| `defaultValue` | `number` | `0` | The index picked to begin with, when the caller does not hold it. |
+| `onValueChange` | `(index: number) => void` | — | Called with the index of a column when it is picked. |
+| `segments` | `number` | `8` | How many lights a column is made of. |
+| `color` | `BarPickerColor` | `'primary'` | Which of the theme's roles the lights are drawn in. |
+| `formatValue` | `(value: number) => string` | `(bar) => String(bar)` | Writes a value for a screen reader: "17 deploys". |
+| `label` | `string` | `'Values'` | What a screen reader calls the row. |
+
+## Notes
+
+#### Scale
+
+A column lights the share of its segments that its value is of `max`, rounded to the nearest segment. Leave `max` out to scale to the largest value in `data`, or set it when the values share a known ceiling.
+
+Any value above zero lights at least one segment. A value of zero lights none, and a value above `max` lights them all.
+
+#### Segments
+
+A column has 8 segments unless `segments` says otherwise. More segments show a value more finely and need more height to stay readable. The stack is 7rem tall.
+
+#### Picking
+
+The whole column is the target, so an empty column can still be picked. The picked column is half as wide again as the others, lit at full strength, and its highest light glows. The others are lit at about a quarter of the same colour.
+
+#### Labels
+
+`label` is drawn under the column and should be a few characters. `name` is the long form a screen reader is given with the value: "Thursday: 17 deploys". Use `formatValue` to say the unit.
+
+#### Keyboard
+
+The row is one Tab stop. Left and right move the pick and wrap at the ends, and Home and End go to the first and last column.
+
+#### Motion
+
+The columns light from the bottom when the component mounts, and the picked column widens as the pick moves. With reduced motion on, both happen at once.
+
+## Example
+
+```tsx
+'use client';
+
+import { useState } from 'react';
+import { BarPicker, type BarPickerBar } from '@/components/panelui/bar-picker';
+
+/** Deploys to production on each day of one week. */
+const WEEK: BarPickerBar[] = [
+ { label: 'Mon', name: 'Monday', value: 9 },
+ { label: 'Tue', name: 'Tuesday', value: 14 },
+ { label: 'Wed', name: 'Wednesday', value: 11 },
+ { label: 'Thu', name: 'Thursday', value: 17 },
+ { label: 'Fri', name: 'Friday', value: 6 },
+ { label: 'Sat', name: 'Saturday', value: 0 },
+ { label: 'Sun', name: 'Sunday', value: 2 },
+];
+
+const TOTAL = WEEK.reduce((sum, day) => sum + day.value, 0);
+
+export default function BarPickerDeploys() {
+ const [day, setDay] = useState(3);
+ const picked = WEEK[day];
+ const share = Math.round((picked.value / TOTAL) * 100);
+
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Deploys
+
This week · {TOTAL}
+
+
+
+
+
+
{picked.name}
+
{picked.value}
+
+
+ {share}% of the week
+
+
+
+
`${value} deploys`}
+ />
+
+
+ );
+}
+```
diff --git a/apps/docs/public/skills/panelui-web/components/command-dock.md b/apps/docs/public/skills/panelui-web/components/command-dock.md
new file mode 100644
index 00000000..3ebdedcd
--- /dev/null
+++ b/apps/docs/public/skills/panelui-web/components/command-dock.md
@@ -0,0 +1,183 @@
+# Command Dock
+
+A small button that opens into a field, with the answer arriving under it a row at a time.
+
+```tsx
+import { CommandDock } from "@/components/panelui/command-dock"
+```
+
+From npm the path is `panelui-web/command-dock`. Install with `npx shadcn@latest add @panelui/command-dock`.
+
+## When to use it
+
+Use it for one question a reader can ask from wherever they are: search these notes, find an action, ask an assistant. It is a small button that opens into a field, and the answer arrives under the field a row at a time.
+
+It runs nothing itself. `onSubmit` is handed what was typed and the rows are whatever you render, so it works the same over a search, a model or a list you already have.
+
+It holds one field and a short list. For a conversation that goes back and forth, use a chat interface, and for a list long enough to scroll, a full search page.
+
+## Usage
+
+```tsx
+
+ Search the notes
+
+
+
+ {found.map((note) => (
+ {note.title}
+ ))}
+
+
+
+```
+
+## Parts
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+```
+
+- `CommandDock.Trigger` — The closed form: a button with your label that opens the dock.
+- `CommandDock.Panel` — The open form. It stays in the page while closed, folded away and inert, so opening and closing can both be seen.
+- `CommandDock.Input` — The field, with a send button. Enter sends too.
+- `CommandDock.Results` — Where the rows go. It takes no room until there is a row or the dock is busy.
+- `CommandDock.Row` — One row of the answer. It slides in when it is added, and `onSelect` makes it a button.
+- `CommandDock.Status` — Three dots and a few words, for while an answer is on its way.
+
+## Props
+
+Every part also takes the props of the element it renders, and a `className`.
+
+#### `CommandDockProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `open` | `boolean` | — | Whether it is open, when the caller holds that. |
+| `defaultOpen` | `boolean` | `false` | Whether it starts open, when the caller does not. |
+| `onOpenChange` | `(open: boolean) => void` | — | Called when it opens or closes. |
+| `value` | `string` | — | What is typed in the field, when the caller holds it. |
+| `onValueChange` | `(value: string) => void` | — | Called with what is typed, each time it changes. |
+| `onSubmit` | `(value: string) => void` | — | Called with what was typed when it is sent. |
+| `busy` | `boolean` | `false` | While true, the field cannot be sent again and `CommandDock.Status` is shown. |
+| `closedWidth` | `string` | `'9rem'` | How wide it is while closed, as a CSS length. |
+
+#### `CommandDockInputProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `submitLabel` | `string` | `'Send'` | What a screen reader calls the send button. |
+
+#### `CommandDockResultsProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `label` | `string` | — | What a screen reader calls the list of rows. |
+
+#### `CommandDockRowProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `onSelect` | `() => void` | — | Called when the row is pressed. Passing this makes the row a button. |
+
+## In a Server Component
+
+`CommandDock` is a client component. A Server Component cannot read `CommandDock.Trigger` off the import, so import each part by name: `CommandDockTrigger`, `CommandDockPanel`, `CommandDockInput`, `CommandDockResults`, `CommandDockRow`, `CommandDockStatus`.
+
+## Notes
+
+#### Sending
+
+`onSubmit` is called with the text, trimmed, when Enter is pressed or the send button is used. It is not called for an empty field or while `busy` is true.
+
+#### Busy
+
+Set `busy` while an answer is on its way. The send button is disabled and the results area opens, so a `CommandDock.Status` in it has somewhere to show. Render the status yourself where you want it among the rows; the first example puts it after them.
+
+#### Rows arriving
+
+A row slides in when it is added to the page. Add rows one at a time, as they come, and they read as an answer being written. Rows rendered together arrive together. Give each row a stable `key`, or a row that was already there is drawn as new.
+
+#### Size
+
+Closed, the dock is `closedWidth` wide, 9rem unless you set it. It has to be a real length for the opening to be animated, so set it to fit your label. Open, it is as wide as its container up to 28rem.
+
+The dock grows downwards as rows arrive and pushes what is under it. Give its container a `min-h-*` if that should not move the page.
+
+#### Opening and closing
+
+Opening, the surface widens, the label fades into the field, and the rows follow. Closing runs the other way: the rows fold up first and the surface narrows after them. With reduced motion on, both happen at once.
+
+Escape closes the dock and returns focus to its button. A press outside it closes it too. What was typed, and the rows, are kept.
+
+#### Screen readers
+
+The field is a search landmark and takes `aria-label`. The results are a list that is announced politely as rows arrive, so they do not cut in on typing.
+
+## Example
+
+```tsx
+'use client';
+
+import { FileTextIcon, SearchIcon } from 'lucide-react';
+import { useRef, useState } from 'react';
+import { CommandDock } from '@/components/panelui/command-dock';
+
+/** What a search of some meeting notes might turn up. */
+const NOTES = [
+ { title: 'Pricing review', where: 'Notes from 3 March' },
+ { title: 'What we decided about trials', where: 'Notes from 19 February' },
+ { title: 'Questions for finance', where: 'Notes from 11 February' },
+ { title: 'Draft of the pricing page', where: 'Shared by Imani' },
+];
+
+export default function CommandDockFind() {
+ const [found, setFound] = useState([]);
+ const [busy, setBusy] = useState(false);
+ const timers = useRef([]);
+
+ // Stands in for a search that answers a row at a time.
+ const find = () => {
+ timers.current.forEach((timer) => window.clearTimeout(timer));
+ setFound([]);
+ setBusy(true);
+ timers.current = NOTES.map((note, index) =>
+ window.setTimeout(() => {
+ setFound((current) => [...current, note]);
+ if (index === NOTES.length - 1) setBusy(false);
+ }, 500 + index * 450)
+ );
+ };
+
+ return (
+
+
+
+ Search the notes
+
+
+
+
+ {found.map((note) => (
+
+
+ {note.title}
+ {note.where}
+
+ ))}
+ {busy ? Reading the notes : null}
+
+
+
+ );
+}
+```
diff --git a/apps/docs/public/skills/panelui-web/components/day-grid.md b/apps/docs/public/skills/panelui-web/components/day-grid.md
new file mode 100644
index 00000000..f259e765
--- /dev/null
+++ b/apps/docs/public/skills/panelui-web/components/day-grid.md
@@ -0,0 +1,184 @@
+# Day Grid
+
+A run of days as a field of dots, each sized by how much happened on it.
+
+```tsx
+import { DayGrid, type DayCell } from "@/components/panelui/day-grid"
+```
+
+From npm the path is `panelui-web/day-grid`. Install with `npx shadcn@latest add @panelui/day-grid`.
+
+## When to use it
+
+Use it to show how much happened on each day of a period: words written, commits pushed, minutes practised. Each day is a dot that grows with the amount, from a speck for an empty day to a full disc for the busiest, and the days can run along rows or down columns.
+
+It shows a pattern, not a figure. Nobody can read a count off the size of a dot, so put the number that matters beside the grid, and give each day a `label`: it is shown in a tag when the pointer is over that day.
+
+For values over two dimensions that are not days, such as hour against weekday, use a table or a chart with axes.
+
+## Usage
+
+```tsx
+const days: DayCell[] = [
+ { level: 0, label: '1 Sep, nothing written' },
+ { level: 2, label: '2 Sep, 880 words' },
+ { level: 4, label: '3 Sep, 2,210 words' },
+]
+
+
+
+```
+
+## Parts
+
+```tsx
+
+
+```
+
+- `DayGrid.Legend` — The five sizes in a row, quietest to busiest, with a word at each end. It is a sibling of the grid, so place it wherever the layout wants it and give it the same `color`.
+
+## Props
+
+Every part also takes the props of the element it renders, and a `className`.
+
+#### `DayGridProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `cells` | `DayCell[]` | **required** | The days, in order. They fill the grid row by row. |
+| `columns` | `number` | `7` | Cells per row. Seven makes each row a week. |
+| `rows` | `number` | — | Cells per column. Giving this fills the grid column by column, and `columns` is ignored. Seven makes each column a week, which lays a long period out wide and short. |
+| `color` | `DayGridColor` | `'primary'` | Which of the theme's roles the dots are drawn in. |
+| `value` | `number \| null` | `null` | The index of the selected day, when the caller holds it. |
+| `onValueChange` | `(index: number) => void` | — | Called with the index of a day when it is pressed. Passing this makes the days buttons; without it the grid is a picture. |
+| `lens` | `boolean` | `true` | Set to `false` for dots that hold still under the pointer. |
+| `label` | `string` | `'Activity'` | What a screen reader calls the grid. |
+
+#### `DayGridLegendProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `color` | `DayGridColor` | `'primary'` | The same role the grid is drawn in. |
+| `less` | `ReactNode` | `'Less'` | The word at the quiet end of the scale. |
+| `more` | `ReactNode` | `'More'` | The word at the busy end of the scale. |
+
+## In a Server Component
+
+`DayGrid` is a client component. A Server Component cannot read `DayGrid.Legend` off the import, so import each part by name: `DayGridLegend`.
+
+## Notes
+
+#### Levels
+
+A day's `level` is a whole number from 0 to 4. Level 0 is a small grey speck and means the day was measured and nothing happened. Levels 1 to 4 are dots of growing size and strength in the chosen colour, and a level 4 dot has a faint glow.
+
+The component does not turn counts into levels, because where the steps fall depends on the data. Decide the four thresholds once and map your counts through them.
+
+#### The lens
+
+Dots within about two cells of the pointer grow, the nearest most. It follows a mouse or a pen and does nothing for touch, where a finger would cover it. Pass `lens={false}` to turn it off. It is also off for anyone who has asked their system for reduced motion.
+
+#### Arriving
+
+The dots grow in from nothing when the grid mounts, one diagonal after the next. With reduced motion on they are simply there.
+
+#### Layout
+
+Days fill the grid row by row and stay round at any width. `columns` defaults to 7, which makes each row a week and the grid tall. For a strip that runs along one line, set `columns` to the number of days.
+
+Give `rows` and the days run down each column, `rows` to a column, for as many columns as it takes. `rows={7}` makes each column a week, which lays a long period out wide and short.
+
+The grid takes the width of its container. Set a `max-w-*` class on it or on its parent to stop the dots growing too large.
+
+#### Labels
+
+`label` on a day is its tag and what a screen reader says for it, so write the date and the figure: "14 May, 6 commits". A day without a label has no tag, and a pressable one is announced by its position and level.
+
+The grid as a whole is announced with its `label` and how many of the days have something on them.
+
+#### Keyboard
+
+When the days are buttons, Tab moves to the selected day, or the first. The arrow keys move to the cell beside, above or below, whichever way the days run, and Home and End go to the first and last day.
+
+## Example
+
+```tsx
+'use client';
+
+import { DayGrid, type DayCell, type DayLevel } from '@/components/panelui/day-grid';
+
+/** Words written on each of the last 112 days, oldest first, starting on a Monday. */
+const WORDS = [
+ 0, 420, 1310, 880, 0, 0, 240, 1650, 1920, 760, 0, 310, 0, 0, 980, 1440, 2210, 1180, 640, 0, 0,
+ 1890, 2460, 1730, 920, 0, 150, 0, 2050, 1380, 0, 1120, 1960, 0, 0, 870, 2320, 1610, 1240, 2580,
+ 430, 0, 0, 0, 760, 1340, 0, 0, 280, 1470, 1980, 2240, 640, 1010, 0, 0, 2120, 940, 1760, 0, 520, 0,
+ 190, 1560, 2380, 1820, 2050, 1290, 0, 0, 0, 610, 1430, 1070, 0, 0, 0, 2290, 2510, 1940, 1680, 730,
+ 0, 360, 1210, 0, 1850, 2400, 1530, 0, 0, 990, 1720, 2160, 0, 1340, 220, 0, 2070, 2450, 1890, 2630,
+ 1160, 0, 0, 1480, 2220, 1960, 2540, 870, 410, 0,
+];
+
+/** A day's count as one of the five sizes. */
+const levelOf = (words: number): DayLevel =>
+ words === 0 ? 0 : words < 600 ? 1 : words < 1300 ? 2 : words < 2000 ? 3 : 4;
+
+const START = new Date(2026, 5, 22);
+
+const CELLS: DayCell[] = WORDS.map((words, index) => {
+ const day = new Date(START);
+ day.setDate(START.getDate() + index);
+ const date = day.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
+ return {
+ level: levelOf(words),
+ label: words === 0 ? `${date}, nothing written` : `${date}, ${words.toLocaleString('en-GB')} words`,
+ };
+});
+
+const WEEKDAYS = ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'];
+
+export default function DayGridWriting() {
+ const total = WORDS.reduce((sum, words) => sum + words, 0);
+ const written = WORDS.filter((words) => words > 0).length;
+
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Words written
+
Last 16 weeks
+
+
+
+
+
+
In total
+
+ {total.toLocaleString('en-GB')}
+
+
+
+ {written} of {WORDS.length} days
+
+
+
+ {/* A week to a column: `rows` runs the days downwards. */}
+
+
+ {WEEKDAYS.map((day, index) => (
+
+ {day}
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+```
diff --git a/apps/docs/public/skills/panelui-web/components/feedback.md b/apps/docs/public/skills/panelui-web/components/feedback.md
new file mode 100644
index 00000000..2e614c31
--- /dev/null
+++ b/apps/docs/public/skills/panelui-web/components/feedback.md
@@ -0,0 +1,196 @@
+# Feedback
+
+A dialog with a well to write in and two actions in the band around it.
+
+```tsx
+import { Feedback } from "@/components/panelui/feedback"
+```
+
+From npm the path is `panelui-web/feedback`. Install with `npx shadcn@latest add @panelui/feedback`.
+
+## When to use it
+
+Use it to ask for a sentence: what went wrong, what to fix first, why somebody gave the score they did. It is a dialog whose body is a place to write, with Cancel and Submit in the band around it.
+
+`Submit` cannot be pressed while the field is empty, and it does not close the dialog. You close it once the message has been sent, so a send that fails still has somewhere to say so.
+
+For a yes-or-no confirmation use a plain dialog. For a form with several fields, build the form.
+
+## Usage
+
+```tsx
+
+ Send feedback
+
+
+ What should we fix first?
+
+
+
+
+
+ send(message)} />
+
+
+
+```
+
+## Parts
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- `Feedback.Trigger` — What opens the dialog. A plain button, or your own through `render`.
+- `Feedback.Content` — The shell: the darker band and everything set in it. `inline` draws it in the page instead of over it.
+- `Feedback.Panel` — The well. The title, the close button and the field go here, along with anything else the question needs.
+- `Feedback.Title` — The question. In a dialog it is also the dialog's accessible name.
+- `Feedback.Close` — The ✕ in the corner of the panel.
+- `Feedback.Field` — The text area. It has no border of its own, because it is already inside a well.
+- `Feedback.Footer` — The row the two actions sit in.
+- `Feedback.Cancel` — Closes the dialog. Call `preventDefault` in `onClick` to do something else, such as going back a step.
+- `Feedback.Submit` — Calls `onSubmit` with the message. Disabled while the field is empty unless you pass `disabled` yourself.
+
+## Props
+
+Every part also takes the props of the element it renders, and a `className`.
+
+#### `FeedbackProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `open` | `boolean` | — | Whether the dialog is open, when the caller holds that. |
+| `onOpenChange` | `(open: boolean) => void` | — | Called when the dialog asks to open or close. |
+| `defaultOpen` | `boolean` | `false` | Whether it starts open, when the caller does not hold it. |
+| `value` | `string` | — | The message, when the caller holds it. Leave unset to let the field keep it. |
+| `defaultValue` | `string` | `''` | The message to start with, when the caller does not hold it. |
+| `onValueChange` | `(value: string) => void` | — | Called with the message each time it changes. |
+| `dismissible` | `boolean` | `true` | Whether a click outside or the Escape key closes the dialog. Turn it off for a message that would be lost by a stray click; Cancel and the close button still work. |
+
+#### `FeedbackTriggerProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `render` | `ReactElement` | — | Your own element to be the trigger, such as a styled button. It is given the click handler and the attributes that tie it to the dialog. |
+
+#### `FeedbackContentProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `inline` | `boolean` | `false` | Draw it in the page instead of over it: no backdrop, no trigger, always there. For a feedback box that is part of the page, such as the end of an article. `open` is ignored, and Cancel and the close button only report through `onOpenChange`. |
+| `blur` | `boolean` | `false` | Frost the page behind the dialog instead of only dimming it. |
+
+#### `FeedbackCloseProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `label` | `string` | — | How the button announces itself to a screen reader. |
+
+#### `FeedbackFieldProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `value` | `string` | — | The message. Leave unset to let the dialog hold it. |
+| `onValueChange` | `(value: string) => void` | — | Called with the message each time it changes. |
+| `minHeight` | `number` | `200` | Room to write in, in pixels, before the field starts to grow. |
+| `maxHeight` | `number` | `360` | How tall the field may grow, in pixels, before the area round it scrolls. |
+
+#### `FeedbackSubmitProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `onSubmit` | `(value: string) => void` | — | Called with the message. The dialog stays open: close it yourself once the message has been sent, so a failure still has somewhere to be shown. |
+
+## In a Server Component
+
+`Feedback` is a client component. A Server Component cannot read `Feedback.Trigger` off the import, so import each part by name: `FeedbackTrigger`, `FeedbackContent`, `FeedbackPanel`, `FeedbackTitle`, `FeedbackClose`, `FeedbackField`, `FeedbackFooter`, `FeedbackCancel`, `FeedbackSubmit`.
+
+## Notes
+
+#### In the page
+
+`` draws the same shell in the page, with no backdrop and nothing to open. Cancel and the close button have nothing to close there, so they only call `onOpenChange(false)`. Use that to clear the field or to hide the box.
+
+#### Closing
+
+A click outside the dialog or the Escape key closes it. Pass `dismissible={false}` to `Feedback` to turn both off, for a message that a stray click should not throw away. Cancel and the close button still work.
+
+#### Keyboard
+
+Enter adds a new line. Command+Enter on a Mac, or Control+Enter elsewhere, sends the message, as long as `Submit` is enabled. Focus is kept inside the dialog while it is open and returns to the trigger when it closes.
+
+#### The empty state
+
+While `Submit` is disabled it drops its fill, where most buttons would only dim. A dimmed accent button still looks like the one to press. Pass `disabled` to decide for yourself when the message can be sent: `disabled={false}` allows an empty one.
+
+#### What it is built on
+
+The dialog is the Base UI dialog, which the package depends on directly. It does not use your project's own dialog component, so it looks the same whichever one you have.
+
+## Example
+
+```tsx
+'use client';
+
+import { useState } from 'react';
+import { Feedback } from '@/components/panelui/feedback';
+
+export default function FeedbackInThePage() {
+ const [message, setMessage] = useState('');
+ const [sent, setSent] = useState(false);
+
+ // Inline, there is nothing to close. Cancel and the ✕ report here instead,
+ // and this page takes that to mean "start again".
+ const reset = () => {
+ setMessage('');
+ setSent(false);
+ };
+
+ return (
+
+
+
+ {sent ? (
+ <>
+ Thank you
+
+ We read every message. If yours needs a reply, you will have one
+ within a working day.
+
+ >
+ ) : (
+ <>
+ What should we fix first?
+
+
+ >
+ )}
+
+
+ {sent ? (
+ Write another
+ ) : (
+ <>
+ Clear
+ setSent(true)} />
+ >
+ )}
+
+
+
+ );
+}
+```
diff --git a/apps/docs/public/skills/panelui-web/components/flow.md b/apps/docs/public/skills/panelui-web/components/flow.md
new file mode 100644
index 00000000..8ce731de
--- /dev/null
+++ b/apps/docs/public/skills/panelui-web/components/flow.md
@@ -0,0 +1,310 @@
+# Flow
+
+A canvas of nodes joined by edges, that you pan, zoom and rearrange.
+
+```tsx
+import { Flow } from "@/components/panelui/flow"
+```
+
+From npm the path is `panelui-web/flow`. Install with `npx shadcn@latest add @panelui/flow`.
+
+## When to use it
+
+Use it to show how things are connected and let somebody move them about: the stages of a pipeline, an automation, the parts of a system. Nodes are boxes with your own contents, edges join them, and the canvas is panned and zoomed.
+
+It has the same parts and props as the app library's Flow, so a graph written for one reads the same in the other.
+
+It lays nothing out for you. Every node is given a position, so for a graph whose shape comes from data you need to work the positions out first. For steps in a fixed order with nothing to rearrange, a list of steps is the simpler thing.
+
+## Usage
+
+```tsx
+
+
+ Draft
+ Edit
+
+
+
+```
+
+## Parts
+
+```tsx
+
+
+
+ …
+
+
+
+
+
+
+
+```
+
+- `Flow.Background` — The pattern behind the graph: dots, lines or crosses. It moves and scales with the graph.
+- `Flow.Node` — A box on the graph. `id` is what edges call it, `position` is where it starts, and its children are yours.
+- `Flow.Handle` — A dot on a node's edge that a connection can be drawn from or dropped on. Give it an `id` to join an edge to it by name.
+- `Flow.Edge` — A line from one node to another: a curve, a routed line or a straight one, optionally dashed, marching and with an arrowhead.
+- `Flow.Group` — A dashed box with a label, round things that belong together. It is dragged as one and an edge can be joined to it.
+- `Flow.Controls` — Buttons to zoom, fit everything in view and lock the canvas.
+- `Flow.MiniMap` — The whole graph, small, with the part in view marked on it.
+
+## Props
+
+Every part also takes the props of the element it renders, and a `className`.
+
+#### `FlowProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `defaultViewport` | `FlowViewport` | — | Where the canvas looks to begin with. |
+| `minZoom` | `number` | `0.3` | The furthest out it can be zoomed. |
+| `maxZoom` | `number` | `2.5` | The furthest in it can be zoomed. |
+| `panOnDrag` | `boolean` | `true` | Whether dragging the pane pans it. |
+| `zoomOnPinch` | `boolean` | `true` | Whether a pinch, or Control with the wheel, zooms it. |
+| `fitViewOnMount` | `boolean` | `false` | Fits every node into view once they have been measured. |
+| `fitViewPadding` | `number` | `48` | The room left round the nodes when fitting them, in pixels. |
+| `onViewportChange` | `(viewport: FlowViewport) => void` | — | Called as the canvas is panned or zoomed. |
+| `onNodeDragEnd` | `(id: string, position: FlowNodePosition) => void` | — | Called with a node's new position when a drag or a key press has moved it. |
+| `onConnect` | `(connection: FlowConnection) => void` | — | Called when a connection drawn from a handle lands on another node. |
+| `isValidConnection` | `(connection: FlowConnection) => boolean` | — | Decides whether a connection may land. Without it, any two different nodes may be joined. |
+| `label` | `string` | `'Diagram'` | What a screen reader calls the canvas. |
+
+#### `FlowBackgroundProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `variant` | `'dots' \| 'lines' \| 'cross' \| 'none'` | `'bezier'` | Dots, a grid of lines, small crosses, or nothing. |
+| `gap` | `number` | `20` | The distance from one mark to the next, in pixels at a zoom of 1. |
+| `size` | `number` | — | How big a mark is: a dot's radius, a line's thickness, half a cross's arm. |
+| `className` | `string` | — | — |
+
+#### `FlowNodeProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `id` | `string` | **required** | What edges and `onNodeDragEnd` call this node. |
+| `position` | `FlowNodePosition` | **required** | Where its top left corner is, on the graph. |
+| `draggable` | `boolean` | `true` | Whether it can be dragged and moved with the arrow keys. |
+| `selected` | `boolean` | `false` | Draws it as the one that has been picked. |
+| `onPress` | `() => void` | — | Called when it is pressed without being dragged, or with Enter. |
+| `onDelete` | `() => void` | — | Called when Delete or Backspace is pressed on it. |
+| `moveStep` | `number` | — | How far one arrow key press moves it, in pixels. Shift moves four times as far. |
+
+#### `FlowGroupProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `label` | `ReactNode` | — | Written in the corner of the group. |
+
+#### `FlowHandleProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `id` | `string` | — | A name for this handle, so an edge can be joined to it and not only to the node. |
+| `position` | `FlowSide` | `'bottom-right'` | Which face of the node it sits on. |
+| `offset` | `number` | — | How far along that face, from 0 to 1. |
+
+#### `FlowEdgeProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `from` | `FlowEndpointReference` | **required** | Where it leaves from: a node's `id`, or `{ node, handle }`. |
+| `to` | `FlowEndpointReference` | **required** | Where it arrives. |
+| `variant` | `FlowEdgeVariant` | `'bezier'` | A curve, a rounded right-angled route, a square one, or a straight line. |
+| `animated` | `boolean` | `false` | Sets the dashes marching towards the target. |
+| `dashed` | `boolean` | `false` | Draws it dashed. |
+| `color` | `FlowColor` | — | One of the theme's roles. Without it the edge is a quiet grey. |
+| `width` | `number` | `2` | How thick the line is, in pixels at a zoom of 1. |
+| `arrow` | `boolean` | `false` | Draws an arrowhead where it arrives. |
+| `fromSide` | `FlowSide` | — | The face it leaves from. Worked out from where the two nodes are when left out. |
+| `toSide` | `FlowSide` | — | The face it arrives at. |
+| `radius` | `number` | `8` | How round the corners of a `smoothstep` route are. |
+| `gap` | `number` | `20` | How far a routed edge runs straight out of a node before it turns. |
+| `curvature` | `number` | `0.25` | How far a `bezier` bulges when its target is behind its source. |
+
+#### `FlowControlsProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `zoom` | `boolean` | `true` | Shows the two zoom buttons. |
+| `fit` | `boolean` | `true` | Shows the button that fits every node into view. |
+| `lock` | `boolean` | `true` | Shows the button that locks the canvas against panning, zooming and dragging. |
+| `step` | `number` | `1.3` | How much one press of a zoom button zooms. |
+| `position` | `FlowCorner` | `'bottom-right'` | Which corner of the canvas they sit in. |
+
+#### `FlowMiniMapProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `width` | `number` | `120` | How wide the map is, in pixels. |
+| `height` | `number` | `80` | How tall the map is, in pixels. |
+| `position` | `FlowCorner` | `'bottom-left'` | Which corner of the canvas it sits in. |
+
+## In a Server Component
+
+`Flow` is a client component. A Server Component cannot read `Flow.Background` off the import, so import each part by name: `FlowBackground`, `FlowNode`, `FlowHandle`, `FlowEdge`, `FlowGroup`, `FlowControls`, `FlowMiniMap`.
+
+## Notes
+
+#### Size
+
+The canvas is 24rem tall and as wide as its container. Set a height with `className`.
+
+It is drawn as a bordered, rounded box. Inside a frame that already has an edge, such as a card or a panel, give it the frame's height and take its own edge off: `className="h-full rounded-none border-0"`. The examples on this page do that.
+
+`fitViewOnMount` fits the graph once. Where the room for the graph changes a great deal, as between a phone and a desktop, lay the nodes out for each and give the canvas a `key` that changes with the layout, so it is fitted again.
+
+#### Positions
+
+`position` is the node's top left corner on the graph, in pixels at a zoom of 1. The canvas holds a node's position after that: a drag moves it, and `onNodeDragEnd` reports where it stopped. To move a node yourself, keep its position in state and pass the new one.
+
+`fitViewOnMount` zooms and pans so every node is in view once they have been measured. It never zooms in past 1.
+
+#### Edges
+
+`from` and `to` are node ids, or `{ node, handle }` to join a handle by its `id`. The faces an edge uses are worked out from where the two nodes are, and change as one is dragged past the other; `fromSide` and `toSide` fix them.
+
+`variant` is `bezier`, `smoothstep`, `step` or `straight`. `animated` sets dashes marching towards the target, and stops for anyone who has asked for reduced motion. `color` names one of the theme's roles; without it an edge is a quiet grey.
+
+#### Panning and zooming
+
+Drag the empty canvas to pan. Zoom with a pinch, or with Control or Command and the wheel. The plain wheel is left to the page, so a diagram in a long page does not trap its scroll. `Flow.Controls` has buttons for the same things, and a lock that stops all of it.
+
+#### Keyboard
+
+The canvas and every node are Tab stops. On the canvas the arrow keys pan, plus and minus zoom and 0 fits everything. On a node the arrow keys move it by `moveStep`, four times as far with Shift, Enter presses it and Delete calls `onDelete`.
+
+A connection can only be drawn with a pointer. Where joining nodes matters to everyone, give them another way to do it, such as a menu on the node.
+
+#### Inside a node
+
+A node is a card by default; pass `className` to change it. Buttons, links and fields inside a node work as usual and do not start a drag. Put `data-no-drag` on anything else that should not.
+
+#### Groups
+
+A group's children are ordinary elements, laid out however you like inside it. They are not nodes: an edge is joined to the group, not to something in it.
+
+## Example
+
+```tsx
+'use client';
+
+import { useCallback, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Flow } from '@/components/panelui/flow';
+
+/** An article on its way to being published. */
+const STAGES = [
+ { id: 'draft', name: 'Draft', detail: '1,840 words' },
+ { id: 'edit', name: 'Edit', detail: 'Two rounds' },
+ { id: 'check', name: 'Fact check', detail: '14 claims' },
+ { id: 'legal', name: 'Legal read', detail: 'One quote to clear' },
+ { id: 'schedule', name: 'Schedule', detail: 'Thursday, 09:00' },
+ { id: 'publish', name: 'Publish', detail: 'Site and newsletter' },
+];
+
+const TAG = {
+ done: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-(--success)',
+ live: 'bg-[color-mix(in_oklab,var(--info)_14%,transparent)] text-(--info)',
+ waiting: 'bg-muted text-muted-foreground',
+};
+
+/**
+ * Whether the canvas has room to lay the graph out across. It is the canvas
+ * that is measured and not the window: in a page with a sidebar the two are
+ * far apart.
+ */
+function useRoom(least: number) {
+ const [wide, setWide] = useState(true);
+ const watch = useCallback(
+ (node: HTMLElement | null) => {
+ if (!node) return;
+ const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= least));
+ observer.observe(node);
+ return () => observer.disconnect();
+ },
+ [least]
+ );
+ return [wide, watch] as const;
+}
+
+export default function FlowPipeline() {
+ // The stage being worked on. Everything before it is done.
+ const [at, setAt] = useState(1);
+ const [wide, watch] = useRoom(720);
+ const columns = wide ? 3 : 2;
+
+ // The stages run along a row and back along the next, like a snake.
+ const place = (index: number) => {
+ const row = Math.floor(index / columns);
+ const along = index % columns;
+ const column = row % 2 ? columns - 1 - along : along;
+ return { x: column * (wide ? 340 : 216), y: row * (wide ? 230 : 160) };
+ };
+
+ return (
+
+
+
+ {STAGES.map((stage, index) => {
+ const state = index < at ? 'done' : index === at ? 'live' : 'waiting';
+ return (
+
+
+ Stage {index + 1}
+
+ {state === 'done' ? 'Done' : state === 'live' ? 'Now' : 'Waiting'}
+
+
+
+
{stage.name}
+
{stage.detail}
+
+
+ );
+ })}
+ {STAGES.slice(1).map((stage, index) => (
+
+ ))}
+ {/* Fewer buttons where the graph needs the room. */}
+
+
+
+
+ setAt(at - 1)}>
+ Back a stage
+
+ setAt(at + 1)}>
+ Advance
+
+
+
+ );
+}
+```
diff --git a/apps/docs/public/skills/panelui-web/components/kpi.md b/apps/docs/public/skills/panelui-web/components/kpi.md
new file mode 100644
index 00000000..024b49e8
--- /dev/null
+++ b/apps/docs/public/skills/panelui-web/components/kpi.md
@@ -0,0 +1,210 @@
+# KPI
+
+One number and what it is doing: its change, its shape over time, its progress to a target.
+
+```tsx
+import { Kpi } from "@/components/panelui/kpi"
+```
+
+From npm the path is `panelui-web/kpi`. Install with `npx shadcn@latest add @panelui/kpi`.
+
+## When to use it
+
+Use it for a number someone checks, with whatever says how it is doing: the change since last time, the shape of it over a period, how far it is towards a target. It has the same parts as the app library's KPI, so a metric reads the same on both.
+
+The number comes first. The trend, the chart and the bar are all small on purpose, and none of them can be read to a value.
+
+For a chart someone needs to read values from, use a chart with an axis. For one bare figure with a label, a `Kpi.Title` and a `Kpi.Value` are enough.
+
+## Usage
+
+```tsx
+
+ Subscribers
+ 18,240
+
+
+```
+
+## Parts
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- `Kpi.Header` — The row over the number. A title in it takes the room between the icon and the actions.
+- `Kpi.Icon` — A small tile for an icon. `tone` colours it as good or bad news.
+- `Kpi.Title` — What the number measures.
+- `Kpi.Actions` — Whatever belongs at the end of the header: a period, a trend badge, a button.
+- `Kpi.Content` — The row the number is in. `layout="inline"` is for a chart beside it.
+- `Kpi.Stat` — Title, value and trend stacked as one block, for a row with a chart at its other end.
+- `Kpi.Value` — The number.
+- `Kpi.Trend` — The change, given as a number. It writes the sign and picks the colour.
+- `Kpi.Chart` — A sparkline of a list of numbers. `inline` gives it a fixed box, `filled` a wash under the line.
+- `Kpi.Progress` — A bar towards a target, with a label and the percentage over it.
+- `Kpi.Footer` — A quiet line under everything.
+- `Kpi.Separator` — A rule between two parts of one card.
+- `Kpi.Group` — Several metrics as one panel, side by side or stacked, with a rule between each and the next.
+
+## Props
+
+Every part also takes the props of the element it renders, and a `className`.
+
+#### `KpiProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `surface` | `boolean` | — | Draws the metric as a card of its own. Leave it off inside a frame or a group. |
+
+#### `KpiIconProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `tone` | `KpiTone` | `neutral` | How the tile is coloured: by good or bad news, or left neutral. |
+
+#### `KpiContentProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `layout` | `'below' \| 'inline'` | `below` | `inline` puts a chart beside the number in a fixed box; `below` leaves it under everything. |
+
+#### `KpiTrendProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `value` | `number` | **required** | The change. Its sign decides the direction. |
+| `format` | `(value: number) => string` | — | Writes the change. Defaults to a signed percentage with one decimal: "+7.8%". |
+| `goodDirection` | `KpiGoodDirection` | `'up'` | Which direction is the good news. `down` for churn, latency, cost; `none` for no colour. |
+| `variant` | `'text' \| 'badge'` | `'text'` | `text` is a line of colour; `badge` puts a pill and an arrow round it. |
+| `caption` | `string` | — | Quiet words after the change: "this month". |
+| `threshold` | `number` | `0` | A change this small or smaller is drawn as no change. |
+
+#### `KpiChartProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `data` | `number[]` | **required** | The values, oldest first. |
+| `color` | `KpiColor` | `'primary'` | Which of the theme's roles the line is drawn in. |
+| `filled` | `boolean` | `false` | Fills under the line with a wash of the same colour. |
+| `height` | `number` | — | How tall the chart is, in pixels. |
+| `inline` | `boolean` | `false` | A fixed box for the end of a `layout="inline"` row. Charts beside numbers of different lengths are then all one width, and line up down a column. |
+| `strokeWidth` | `number` | `2` | How thick the line is, in pixels. |
+
+#### `KpiProgressProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `value` | `number` | **required** | How far along. |
+| `maxValue` | `number` | `100` | What `value` is out of. |
+| `label` | `ReactNode` | — | Words at the start of the row over the bar: "of 5,000". |
+| `showValueLabel` | `boolean` | `false` | Writes the percentage at the end of the row over the bar. |
+| `color` | `KpiColor` | `'primary'` | Which of the theme's roles the bar is drawn in. |
+
+#### `KpiGroupProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `orientation` | `'horizontal' \| 'vertical'` | — | Side by side with upright rules, or stacked with level ones. |
+
+## Notes
+
+#### Trend colours
+
+`Kpi.Trend` is drawn in the success colour when the number moved the good way, in the destructive colour when it moved the bad way, and in the muted colour when it did not move. Up is good unless you say otherwise: pass `goodDirection="down"` for a metric where less is better, or `"none"` for one where neither is.
+
+`threshold` is the size of change that still counts as none. With `threshold={0.5}`, a change of 0.3 is drawn flat.
+
+#### Writing the change
+
+The change is written as a signed percentage with one decimal. `format` is given the value and returns the text, for a change in another unit. `caption` adds quiet words after it and is read out with it.
+
+#### The chart
+
+`data` is a list of numbers, oldest first. The line is scaled between the lowest and highest of them, so it shows the shape of the change and says nothing about its size. That is why it has no axis.
+
+`inline` fixes the chart at 128 by 56 pixels. In a column of cards with titles of different lengths, the charts are then all one width and line up down the right-hand edge.
+
+#### Colour
+
+`color` on `Kpi.Chart` and `Kpi.Progress` names one of the theme's roles and defaults to `primary`.
+
+#### In a group
+
+`Kpi.Group` pads its children on the sides that face a rule. Put `Kpi` elements straight inside it. In a horizontal group the metrics share the width equally, so keep titles to a word or two, or stack them with `orientation="vertical"`.
+
+#### Server Components
+
+KPI holds no state, so it is not a client module. It can be rendered from a Server Component as written, and it adds no JavaScript to the page.
+
+## Example
+
+```tsx
+import { Kpi } from '@/components/panelui/kpi';
+
+/** Each figure over the last thirty days, oldest first. */
+const SUBSCRIBERS = [152, 154, 153, 157, 161, 160, 164, 169, 168, 172, 176, 175, 179, 182];
+const UNSUBSCRIBES = [14, 13, 13, 12, 12, 11, 12, 10, 10, 9, 9, 8, 8, 7];
+const OPENS = [41, 42, 44, 43, 45, 44, 46, 45, 47, 46, 46, 48, 47, 47];
+
+export default function KpiOverview() {
+ return (
+ // A frame: a band that names what is inside, round a panel that holds it.
+
+
+
Newsletter
+
Last 30 days
+
+
+
+
+
+
+
+ Subscribers
+ 18,240
+
+
+
+
+
+
+
+
+ Unsubscribe rate
+ 0.8%
+
+
+
+
+
+
+
+
+ Open rate
+ 47.3%
+
+
+
+
+
+
+
+
+ );
+}
+```
diff --git a/apps/docs/public/skills/panelui-web/components/lane-board.md b/apps/docs/public/skills/panelui-web/components/lane-board.md
new file mode 100644
index 00000000..533cd419
--- /dev/null
+++ b/apps/docs/public/skills/panelui-web/components/lane-board.md
@@ -0,0 +1,229 @@
+# Lane Board
+
+Lanes of cards that can be dragged within a lane and from one lane to another.
+
+```tsx
+import { LaneBoard, type LaneBoardLane } from "@/components/panelui/lane-board"
+```
+
+From npm the path is `panelui-web/lane-board`. Install with `npx shadcn@latest add @panelui/lane-board`.
+
+## When to use it
+
+Use it for things that move through a few stages: changes going into a release, tasks, orders. Each stage is a lane with its cards and a count, and a card is dragged to another place in its lane or into another lane.
+
+The board does not keep the cards. You give it the lanes and it tells you what they should become when a card is set down, so the order on screen is always the order in your state.
+
+The board has a fixed height. A lane with more cards than fit scrolls on its own, and on a narrow screen the lanes scroll sideways. For one list that only needs reordering, a sortable list is the simpler thing.
+
+## Usage
+
+```tsx
+const [lanes, setLanes] = useState[]>([
+ { id: 'building', title: 'Building', cards: [{ id: 'REL-224', title: 'Faster search' }] },
+ { id: 'shipped', title: 'Shipped', cards: [] },
+])
+
+ (
+ <>
+ {change.title}
+ {change.id}
+ >
+ )}
+/>
+```
+
+## Parts
+
+```tsx
+ (
+ <>
+
+
+
+
+
+
+ >
+ )}
+/>
+```
+
+- `LaneBoard.Title` — A card's name.
+- `LaneBoard.Code` — A card's reference, in a fixed-width face so a column of them lines up.
+- `LaneBoard.Row` — The last line of a card, with something at each end.
+- `LaneBoard.Steps` — How far along a card is: a row of dashes with `value` of `max` lit, in the theme colour named by `color`.
+- `LaneBoard.Tag` — A word in a pill, for a card's state.
+- `LaneBoard.Meta` — A short figure at the end of the row: a time, a count.
+
+## Props
+
+Every part also takes the props of the element it renders, and a `className`.
+
+#### `LaneBoardProps`
+
+| Prop | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `lanes` | `LaneBoardLane[]` | — | The lanes and their cards, when the caller holds them. |
+| `defaultLanes` | `LaneBoardLane[]` | — | The lanes to begin with, when the caller does not hold them. |
+| `onLanesChange` | `(lanes: LaneBoardLane[]) => void` | — | Called with the lanes as they should now be, when a card is set down somewhere new. |
+| `onMove` | `(move: LaneBoardMove) => void` | — | Called with the one card that moved, and where from and to. |
+| `renderCard` | `(card: T, state: { lane: LaneBoardLane; carried: boolean }) => ReactNode` | **required** | Draws the inside of a card. |
+| `renderLaneFooter` | `(lane: LaneBoardLane