diff --git a/components.json b/components.json
index 2a427859..16ec6f85 100644
--- a/components.json
+++ b/components.json
@@ -21,5 +21,7 @@
},
"menuColor": "default",
"menuAccent": "subtle",
- "registries": {}
+ "registries": {
+ "@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json"
+ }
}
diff --git a/package.json b/package.json
index 2fd84f4e..2b636766 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "busk-map",
- "version": "1.0.3",
+ "version": "1.1.0",
"private": true,
"scripts": {
"dev": "node scripts/copy-maplibre-worker.mjs && next dev",
diff --git a/src/app/brand/page.tsx b/src/app/brand/page.tsx
index fd5ab68f..77143f18 100644
--- a/src/app/brand/page.tsx
+++ b/src/app/brand/page.tsx
@@ -8,7 +8,7 @@ import { ASSET_GROUPS, COMPONENT_COUNT, KIT, SCREENS } from "@/components/brand/
import { PhoneFold } from "@/components/brand/phone-fold";
import { HexCell } from "@/components/honeycomb/hex-cell";
import { Icon } from "@/components/honeycomb/icon";
-import { Loader } from "@/components/honeycomb/loader";
+import { Spinner } from "@/components/ui/spinner";
import { Logo, QlooWordmark } from "@/components/honeycomb/logo";
import { ScoreCell } from "@/components/honeycomb/score-cell";
import { RankTile } from "@/components/honeycomb/rank-tile";
@@ -256,7 +256,7 @@ export default function BrandPage() {
- Working
+ Working
diff --git a/src/components/ai-elements/plan.tsx b/src/components/ai-elements/plan.tsx
new file mode 100644
index 00000000..02ea4a15
--- /dev/null
+++ b/src/components/ai-elements/plan.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+// AI Elements Plan (registry @ai-elements/plan), restyled for Busk Map: the motion-based Shimmer is replaced by the core
+// `shimmer` class (no motion dependency), PlanTitle and PlanDescription keep the caller's type (no CardTitle display face,
+// no string-only children), and the trigger uses the DS chevron. Card and Collapsible are the vendored, restyled ones.
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+} from "@/components/ui/card";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Icon } from "@/components/honeycomb/icon";
+import { cn } from "@/lib/utils";
+import type { ComponentProps } from "react";
+import { createContext, useContext, useMemo } from "react";
+
+interface PlanContextValue {
+ isStreaming: boolean;
+}
+
+const PlanContext = createContext(null);
+
+const usePlan = () => {
+ const context = useContext(PlanContext);
+ if (!context) {
+ throw new Error("Plan components must be used within Plan");
+ }
+ return context;
+};
+
+/** While streaming, the words shimmer (the core `shimmer` class; still under reduced motion). */
+const streamingText = "shimmer motion-reduce:shimmer-none";
+
+export type PlanProps = ComponentProps & {
+ isStreaming?: boolean;
+};
+
+export const Plan = ({
+ className,
+ isStreaming = false,
+ children,
+ ...props
+}: PlanProps) => {
+ const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export type PlanHeaderProps = ComponentProps;
+
+export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
+
+);
+
+export type PlanTitleProps = ComponentProps<"div">;
+
+export const PlanTitle = ({ className, ...props }: PlanTitleProps) => {
+ const { isStreaming } = usePlan();
+
+ return (
+
+ );
+};
+
+export type PlanDescriptionProps = ComponentProps;
+
+export const PlanDescription = ({
+ className,
+ ...props
+}: PlanDescriptionProps) => {
+ const { isStreaming } = usePlan();
+
+ return (
+
+ );
+};
+
+export type PlanActionProps = ComponentProps;
+
+export const PlanAction = (props: PlanActionProps) => (
+
+);
+
+export type PlanContentProps = ComponentProps;
+
+export const PlanContent = (props: PlanContentProps) => (
+
+
+
+);
+
+export type PlanFooterProps = ComponentProps<"div">;
+
+export const PlanFooter = (props: PlanFooterProps) => (
+
+);
+
+export type PlanTriggerProps = ComponentProps;
+
+export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
+
+
+
+);
diff --git a/src/components/ai-elements/sources.tsx b/src/components/ai-elements/sources.tsx
new file mode 100644
index 00000000..d2a3d1f8
--- /dev/null
+++ b/src/components/ai-elements/sources.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+// AI Elements Sources (registry @ai-elements/sources), restyled for Busk Map: no prose colour or size of its own (the caller's
+// type), the DS icons instead of lucide, no enter/exit animation, and Source takes a className. Busk Map ships it open
+// (defaultOpen): the data credits (OSM, Open-Meteo, Qloo, Spotify, Last.fm) must stay visible.
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Icon } from "@/components/honeycomb/icon";
+import { cn } from "@/lib/utils";
+import type { ComponentProps } from "react";
+
+export type SourcesProps = ComponentProps;
+
+export const Sources = ({ className, ...props }: SourcesProps) => (
+
+);
+
+export type SourcesTriggerProps = ComponentProps & {
+ count: number;
+};
+
+export const SourcesTrigger = ({
+ className,
+ count,
+ children,
+ ...props
+}: SourcesTriggerProps) => (
+
+ {children ?? (
+ <>
+ Used {count} sources
+
+ >
+ )}
+
+);
+
+export type SourcesContentProps = ComponentProps;
+
+export const SourcesContent = ({
+ className,
+ ...props
+}: SourcesContentProps) => (
+
+);
+
+export type SourceProps = ComponentProps<"a">;
+
+export const Source = ({ href, title, children, className, ...props }: SourceProps) => (
+
+ {children ?? (
+ <>
+
+ {title}
+ >
+ )}
+
+);
diff --git a/src/components/brand/brand-components.tsx b/src/components/brand/brand-components.tsx
index 5b14d871..293b1b97 100644
--- a/src/components/brand/brand-components.tsx
+++ b/src/components/brand/brand-components.tsx
@@ -14,7 +14,7 @@ import { ExampleActCard } from "@/components/honeycomb/example-act-card";
import { FormMessage } from "@/components/honeycomb/form-message";
import { HexCell } from "@/components/honeycomb/hex-cell";
import { LeanBar } from "@/components/honeycomb/lean-bar";
-import { Loader } from "@/components/honeycomb/loader";
+import { Spinner } from "@/components/ui/spinner";
import { QlooWordmark } from "@/components/honeycomb/logo";
import { NoteCard } from "@/components/honeycomb/note-card";
import { ScoreCell } from "@/components/honeycomb/score-cell";
@@ -187,7 +187,7 @@ export function PlanStates() {
Loader · Numbers
-
+
diff --git a/src/components/forms/time-slot-picker.tsx b/src/components/forms/time-slot-picker.tsx
index 16b3e5a1..317abd7f 100644
--- a/src/components/forms/time-slot-picker.tsx
+++ b/src/components/forms/time-slot-picker.tsx
@@ -1,8 +1,9 @@
"use client";
-import { useId, useRef, type KeyboardEvent } from "react";
+import { useId } from "react";
import { cn } from "cn";
import { formatHour } from "@/lib/slot";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { NAMED_SLOTS, type NamedSlot, type TimeChoice } from "@/lib/time-slots";
/** The four slots, as the DS draws them (from lib/time-slots.ts): Morning 9 am–12 pm, Lunch 12–3 pm, Afternoon 3–6 pm, Evening 6–9 pm. */
@@ -38,7 +39,7 @@ const SLOT =
"min-h-14 cursor-pointer rounded-none border-2 border-border bg-card px-3 py-2 text-left text-foreground transition-colors outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring focus-visible:shadow-none aria-checked:border-rule aria-checked:bg-primary aria-checked:text-primary-foreground motion-reduce:transition-none";
/**
- * DS TimeSlotPicker: one choice at a time, with radio semantics (a radiogroup, arrow keys move and pick, one tab stop).
+ * DS TimeSlotPicker: one choice at a time, on shadcn RadioGroup (a radiogroup, arrow keys move and pick, one tab stop).
* With `anyTime`: the label "Time (optional)", Any time first (dashed, "Busk Map picks") and its hint when it is checked.
* Without it: the label "Time" and the four slots only. `custom` is the hours of an old link that match no named slot:
* shown as a "From your link" choice so they are not lost. An empty value checks nothing.
@@ -63,51 +64,39 @@ export function TimeSlotPicker({
describedBy?: string;
}) {
const labelId = useId();
- const refs = useRef<(HTMLButtonElement | null)[]>([]);
const quick = QUICK_SLOTS.map((s) => ({ id: s.id as TimeSlotId, label: s.label, range: slotRange(s.from, s.to) }));
const options = [
...(anyTime ? [{ id: "any" as TimeSlotId, label: "Any time", range: "Busk Map picks" }] : []),
...quick,
...(custom ? [{ id: "custom" as TimeSlotId, label: slotRange(custom.startHour, custom.endHour), range: "From your link" }] : []),
];
- const checkedIndex = options.findIndex((o) => o.id === value);
- const tabIndexOf = (i: number) => (i === (checkedIndex >= 0 ? checkedIndex : 0) ? 0 : -1);
-
- function onKeyDown(e: KeyboardEvent, i: number) {
- const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
- if (!step) return;
- e.preventDefault();
- const next = (i + step + options.length) % options.length;
- refs.current[next]?.focus();
- onChange(options[next].id);
- }
return (
+
);
}
diff --git a/src/components/honeycomb/banner.tsx b/src/components/honeycomb/banner.tsx
index a9b9f864..6611a3cb 100644
--- a/src/components/honeycomb/banner.tsx
+++ b/src/components/honeycomb/banner.tsx
@@ -1,26 +1,18 @@
import type { ReactNode } from "react";
-import { Icon, type IconName } from "@/components/honeycomb/icon";
-import { cn } from "cn";
+import { Alert } from "@/components/ui/alert";
export type BannerTone = "info" | "test" | "offline" | "less";
-// The DS maps the tones to info / info / offline / footfall (its BANNER_ICON): test data uses the info glyph.
-const ICON: Record = { info: "info", test: "info", offline: "offline", less: "footfall" };
-const STYLE: Record = {
- info: "bg-accent text-accent-foreground",
- // Test data: the hatch, with the words on a solid chip so they stay readable.
- test: "bm-hatch-test text-accent-foreground [&>span]:bg-accent [&>span]:px-1.5",
- offline: "bg-foreground text-background",
- less: "bg-secondary text-secondary-foreground",
-};
+// The DS maps the tones to info / info / offline / footfall (its BANNER_ICON): the banner-* Alert tones carry the glyphs.
+const VARIANT = { info: "banner-info", test: "banner-test", offline: "banner-offline", less: "banner-less" } as const;
-/** A full-width strip of context above a page or form: planning again, test data, offline, less data. A polite status. */
+/** A full-width strip of context above a page or form: planning again, test data, offline, less data. A polite status (shadcn Alert). */
export function Banner({ tone = "info", action, children, className, testId }: { tone?: BannerTone; action?: ReactNode; children: ReactNode; className?: string; testId?: string }) {
return (
-
-
- {children}
- {action}
-
+
+
+ {children}
+
+
);
}
diff --git a/src/components/honeycomb/both-agree-badge.tsx b/src/components/honeycomb/both-agree-badge.tsx
index 76a35d01..6afea004 100644
--- a/src/components/honeycomb/both-agree-badge.tsx
+++ b/src/components/honeycomb/both-agree-badge.tsx
@@ -2,9 +2,9 @@ import { Icon } from "@/components/honeycomb/icon";
import { Badge } from "@/components/ui/badge";
/** The plain AI also named this pitch: a confidence signal only, it never changes a score. */
-export function BothAgreeBadge({ title }: { title?: string }) {
+export function BothAgreeBadge() {
return (
-
+
Both agree
diff --git a/src/components/honeycomb/chip.tsx b/src/components/honeycomb/chip.tsx
index 704e7048..b3fe38eb 100644
--- a/src/components/honeycomb/chip.tsx
+++ b/src/components/honeycomb/chip.tsx
@@ -1,10 +1,10 @@
import type { ComponentProps, ReactNode } from "react";
-import { cn } from "cn";
+import { Badge } from "@/components/ui/badge";
+import { Toggle } from "@/components/ui/toggle";
-const base =
- "inline-flex min-h-8 items-center gap-1.5 rounded-none border-[1.5px] border-rule px-3 py-1 text-sm leading-5 font-sans";
+const iconSlot = (icon: ReactNode) => (icon ? {icon} : null);
-/** A square chip. With `selected` it is a toggle (`aria-pressed`) and inverts when on; without, a plain label. */
+/** A square chip. With `selected` it is a toggle (`aria-pressed`, shadcn Toggle) and inverts when on; without, a plain label (shadcn Badge). */
export function Chip({
icon,
selected,
@@ -14,22 +14,16 @@ export function Chip({
}: Omit, "children"> & { icon?: ReactNode; selected?: boolean; children: ReactNode }) {
if (selected === undefined && !props.onClick) {
return (
-
- {icon ? {icon} : null}
+
+ {iconSlot(icon)}
{children}
-
+
);
}
return (
-
+
);
}
diff --git a/src/components/honeycomb/disclosure.tsx b/src/components/honeycomb/disclosure.tsx
index 9b6f0427..36cbfe1d 100644
--- a/src/components/honeycomb/disclosure.tsx
+++ b/src/components/honeycomb/disclosure.tsx
@@ -1,10 +1,13 @@
+"use client";
+
import type { ReactNode } from "react";
import { Icon } from "@/components/honeycomb/icon";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "cn";
/**
* A labelled row that opens to show more: "Other scored pitches (25)", "Data returned", "Every check (13)".
- * A native , so keyboard and screen readers behave natively. The chevron turns when open; the count is mono.
+ * Built on shadcn Collapsible: the trigger is a button with aria-expanded. The chevron turns when open; the count is mono.
*/
export function Disclosure({
label,
@@ -13,17 +16,18 @@ export function Disclosure({
children,
className,
...props
-}: { label: ReactNode; count?: number; open?: boolean; children: ReactNode; className?: string } & Omit, "children" | "open">) {
+}: { label: ReactNode; count?: number; /** Starts open. */ open?: boolean; children: ReactNode; className?: string } & Omit, "children" | "open" | "defaultOpen">) {
return (
-
-
+
+
{label}
{count != null ? {` (${count})`} : null}
-
-
-
{children}
-
+
+
+ {/* forceMount: closed content stays in the page (hidden), as a keeps it, so find-in-page and links into it still work. */}
+ {children}
+
);
}
diff --git a/src/components/honeycomb/discovery-badge.tsx b/src/components/honeycomb/discovery-badge.tsx
index 6846db74..7fdc3cb4 100644
--- a/src/components/honeycomb/discovery-badge.tsx
+++ b/src/components/honeycomb/discovery-badge.tsx
@@ -10,7 +10,7 @@ export type Discovery = "hidden-gem" | "usual" | "none" | null | undefined;
export function DiscoveryBadge({ discovery, className }: { discovery: Discovery; className?: string }) {
if (discovery === "hidden-gem") {
return (
-
+
Hidden gem
diff --git a/src/components/honeycomb/form-message.tsx b/src/components/honeycomb/form-message.tsx
index c93afbae..f16fb1d9 100644
--- a/src/components/honeycomb/form-message.tsx
+++ b/src/components/honeycomb/form-message.tsx
@@ -1,13 +1,15 @@
import type { ReactNode } from "react";
import { Icon } from "@/components/honeycomb/icon";
import { cn } from "cn";
+import { FieldDescription, FieldError } from "@/components/ui/field";
export type FormMessageTone = "note" | "error" | "success";
/**
* The line under a field or form: a note while something loads, an error that names the field it is about, or a
* success. An error carries a warning icon and bold text (never colour alone). Errors are `role="alert"` unless
- * `alert={false}`: use that when a separate Alert already announces the same error, so it is read once.
+ * `alert={false}`: use that when a separate Alert already announces the same error, so it is read once. Built on shadcn Field
+ * (FieldError for errors, FieldDescription for notes and successes).
*/
export function FormMessage({
tone = "note",
@@ -23,22 +25,24 @@ export function FormMessage({
children: ReactNode;
className?: string;
}) {
- const role = tone === "error" ? ((alert ?? true) ? "alert" : undefined) : tone === "success" ? "status" : alert ? "status" : undefined;
+ const icon = tone === "error" ? : tone === "success" ? : null;
+ if (tone === "error")
+ return (
+
+ {icon}
+ {children}
+
+ );
return (
-
+
);
}
diff --git a/src/components/honeycomb/icon-action.tsx b/src/components/honeycomb/icon-action.tsx
index d062ed5f..77b57aca 100644
--- a/src/components/honeycomb/icon-action.tsx
+++ b/src/components/honeycomb/icon-action.tsx
@@ -1,16 +1,12 @@
import type { ComponentProps } from "react";
-import { cn } from "cn";
import { Icon, type IconName } from "@/components/honeycomb/icon";
+import { Button } from "@/components/ui/button";
-const variants = {
- ghost: "border-0 bg-transparent hover:bg-muted",
- outline: "border-2 border-rule bg-card hover:bg-muted",
- primary: "border-2 border-rule bg-primary text-primary-foreground",
-} as const;
+const variants = { ghost: "icon-ghost", outline: "icon-outline", primary: "icon-primary" } as const;
/**
- * A square icon-only button: 44px, or 36px (`sm`) in dense rows. The `label` is required: it is the accessible name (no
- * tooltip). Square, one focus ring, no glyph characters: the icon is always a DS Icon.
+ * A square icon-only button (shadcn Button): 44px, or 36px (`sm`) in dense rows. The `label` is required: it is the accessible
+ * name (no tooltip). Square, one focus ring, no glyph characters: the icon is always a DS Icon.
*/
export function IconAction({
icon,
@@ -31,21 +27,17 @@ export function IconAction({
size?: "md" | "sm";
}) {
return (
-
+
+
);
}
diff --git a/src/components/honeycomb/info-tag.tsx b/src/components/honeycomb/info-tag.tsx
index b28f03b0..2b496e10 100644
--- a/src/components/honeycomb/info-tag.tsx
+++ b/src/components/honeycomb/info-tag.tsx
@@ -3,6 +3,7 @@ import { cn } from "cn";
import { Icon, type IconName } from "@/components/honeycomb/icon";
import { SourceLogo } from "@/components/honeycomb/source-logo";
import type { MentionSource } from "@/lib/mention";
+import { Badge } from "@/components/ui/badge";
const tones = {
neutral: "border-input text-foreground",
@@ -31,17 +32,9 @@ export function InfoTag({
}) {
if (source) return ;
return (
-
+
{icon ? : null}
{children}
-
+
);
}
diff --git a/src/components/honeycomb/loader.tsx b/src/components/honeycomb/loader.tsx
deleted file mode 100644
index c5bd15a4..00000000
--- a/src/components/honeycomb/loader.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { cn } from "cn";
-
-/** Pointy-top hexagon points, like Qloo's H3 cells (the DS's hexPts). */
-function hexPts(cx0: number, cy0: number, R: number): string {
- const p: string[] = [];
- for (let i = 0; i < 6; i++) {
- const a = (Math.PI / 180) * (60 * i - 90);
- p.push(`${(cx0 + R * Math.cos(a)).toFixed(2)},${(cy0 + R * Math.sin(a)).toFixed(2)}`);
- }
- return p.join(" ");
-}
-
-const CORE = hexPts(24, 24, 6.6);
-
-/**
- * The pitch ring as the loader (ported 1:1 from the DS Loader): the ring turns, the performer's cell stays put. Add `bm-loader-think`
- * for the slower ring with a breathing core. It stops under reduced motion but stays drawn. `label` names it for screen readers.
- */
-export function Loader({ size = 24, label, className }: { size?: number; label?: string; className?: string }) {
- return (
-
-
-
- );
-}
diff --git a/src/components/honeycomb/note-card.tsx b/src/components/honeycomb/note-card.tsx
index 8e660092..82fb3c22 100644
--- a/src/components/honeycomb/note-card.tsx
+++ b/src/components/honeycomb/note-card.tsx
@@ -1,15 +1,19 @@
import type { ReactNode } from "react";
import { cn } from "cn";
+import { Card, CardContent } from "@/components/ui/card";
-/** A short titled note. `variant="next"` is the dashed "Next · not built yet" card: planned work is always labelled. */
+/** A short titled note (shadcn Card). `variant="next"` is the dashed "Next · not built yet" card: planned work is always labelled. */
export function NoteCard({ title, variant, children, className }: { title?: ReactNode; variant?: "next"; children: ReactNode; className?: string }) {
const next = variant === "next";
return (
-
- {/* A heading, so planned work is findable by its label. */}
+
+ {/* A heading (not CardTitle, a div), so planned work is findable by its label. */}
{next ?
Next · not built yet
: null}
{title ?
{title}
: null}
-
{children}
-
+ {children}
+
);
}
diff --git a/src/components/honeycomb/repertoire/repertoire-funnel.tsx b/src/components/honeycomb/repertoire/repertoire-funnel.tsx
index ee2fb79d..9b52d88f 100644
--- a/src/components/honeycomb/repertoire/repertoire-funnel.tsx
+++ b/src/components/honeycomb/repertoire/repertoire-funnel.tsx
@@ -1,6 +1,6 @@
import { cn } from "cn";
import { HexCell } from "@/components/honeycomb/hex-cell";
-import { Loader } from "@/components/honeycomb/loader";
+import { Spinner } from "@/components/ui/spinner";
import type { RepertoireTotals } from "./types";
/** The DS formatCount: 1.2K, 12K, 3.4M. Counts under 1000 stay as they are. */
@@ -45,7 +45,7 @@ export function RepertoireFunnel({ totals, live, className }: { totals: Partial<
const text = fmt(n);
const name = n === 1 ? s.one : s.many;
items.push(
-
+
3 ? 10 : 12} className={cn(CELL, s.qloo && "text-qloo")} label={`${n} ${name} (${s.src})`}>
{text}
@@ -65,7 +65,7 @@ export function RepertoireFunnel({ totals, live, className }: { totals: Partial<
className={CELL}
label={ready ? `${toLearn} ${toLearn === 1 ? "song" : "songs"} to learn` : "Choosing songs to learn"}
>
- {ready ? text : }
+ {ready ? text : }
{ready ? (toLearn === 1 ? "song to learn" : "songs to learn") : "choosing songs"}
diff --git a/src/components/honeycomb/repertoire/repertoire-research.tsx b/src/components/honeycomb/repertoire/repertoire-research.tsx
index 13e56a26..826443e9 100644
--- a/src/components/honeycomb/repertoire/repertoire-research.tsx
+++ b/src/components/honeycomb/repertoire/repertoire-research.tsx
@@ -2,12 +2,14 @@
import { SourceLogo } from "@/components/honeycomb/source-logo";
import type { MentionSource } from "@/lib/mention";
-import { useId } from "react";
import { cn } from "cn";
import { HexCell, initialsOf } from "@/components/honeycomb/hex-cell";
import { AgentLockup } from "@/components/honeycomb/agent-lockup";
import { Icon, type IconName } from "@/components/honeycomb/icon";
import { StepCell } from "@/components/honeycomb/step-cell";
+import { Badge } from "@/components/ui/badge";
+import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { RepertoireFunnel } from "./repertoire-funnel";
import type { RepertoireStep, RepertoireStepKey, RepertoireTotals, StepStatus } from "./types";
@@ -28,7 +30,6 @@ export const REPERTOIRE_STEPS: RepertoireStepInfo[] = [
{ key: "summary", title: "Summing up your act", sources: [], icon: "pencil" },
];
-const TAG = "px-1.5 py-[3px] font-mono text-[11px] leading-[normal] font-bold tracking-[0.06em] uppercase";
const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:shadow-none";
const STATUS_TAG: Partial> = { running: "Working", failed: "Didn't work", skipped: "Skipped" };
@@ -42,15 +43,20 @@ function StepCard({ info, step, n }: { info: RepertoireStepInfo; step: Repertoir
// The source it ran on, once known, else every source it can read; one source goes in the running mark's centre cell.
const sources = step?.source && info.sources.includes(step.source) ? [step.source] : info.sources;
return (
-
,
diff --git a/src/components/honeycomb/slot-passed.tsx b/src/components/honeycomb/slot-passed.tsx
index 26fd424c..e71f076e 100644
--- a/src/components/honeycomb/slot-passed.tsx
+++ b/src/components/honeycomb/slot-passed.tsx
@@ -1,40 +1,69 @@
import Link from "next/link";
-import { Icon } from "@/components/honeycomb/icon";
import { cn } from "cn";
+import { Icon } from "@/components/honeycomb/icon";
import { HexCell } from "@/components/honeycomb/hex-cell";
import { Button } from "@/components/ui/button";
+import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
+import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item";
-/** A stored busk whose slot is over: a dashed box with a clock cell, the slot struck through in mono, and a way to plan again. Never re-plans by itself. */
-export function SlotPassed({ slotLabel, againHref, alone = false }: { slotLabel: string; againHref: string; alone?: boolean }) {
+const SLOT_CLS = "font-mono text-xs font-bold tracking-[0.06em] text-muted-foreground uppercase line-through";
+const H2_CLS = "m-0 font-display leading-none font-extrabold uppercase";
+
+function Again({ href }: { href: string }) {
return (
-
-
-
-
-
- {slotLabel}
-
This slot has passed
-
- {alone
- ? "There's no stored research for this busk. Plan again with the same act for a new date."
- : "This busk was for a time that's gone, so it's shown as it was. Plan again with the same act for a new date."}
-
-
-
+
+ );
+}
+
+/**
+ * A stored busk whose slot is over: a clock cell, the slot struck through in mono, and a way to plan again. Never re-plans by itself.
+ * `alone` (no stored research): the centred empty state (shadcn Empty). Otherwise the dashed row above the busk (shadcn Item).
+ */
+export function SlotPassed({ slotLabel, againHref, alone = false }: { slotLabel: string; againHref: string; alone?: boolean }) {
+ if (alone)
+ return (
+
+
+
+
+
+
+
+ {slotLabel}
+
+
This slot has passed
+
+ There's no stored research for this busk. Plan again with the same act for a new date.
+
+
+
+
+ This busk was for a time that's gone, so it's shown as it was. Plan again with the same act for a new date.
+
+
+
+
+
+
);
}
diff --git a/src/components/honeycomb/song-actions.tsx b/src/components/honeycomb/song-actions.tsx
index 79dd48ca..195d06bc 100644
--- a/src/components/honeycomb/song-actions.tsx
+++ b/src/components/honeycomb/song-actions.tsx
@@ -4,7 +4,7 @@ import { LearnItMenu } from "@/components/songs/learn-it-menu";
/**
* THE song action set. Each action shows only when its callback is given, always in this order: Learn it, Learned, Not for
* me, Undo, Move to can sing, Add (or Added), Remove. Built from IconAction, so every one is an icon with its name as the
- * accessible name and tooltip. Put it in a SongRow's `actions` slot.
+ * accessible name. Put it in a SongRow's `actions` slot.
*/
export function SongActions({
title,
diff --git a/src/components/honeycomb/song-row.tsx b/src/components/honeycomb/song-row.tsx
index b9ecff64..8e3566c6 100644
--- a/src/components/honeycomb/song-row.tsx
+++ b/src/components/honeycomb/song-row.tsx
@@ -5,6 +5,7 @@ import { Icon } from "@/components/honeycomb/icon";
import { Mention } from "@/components/honeycomb/mention";
import { SourceLogo } from "@/components/honeycomb/source-logo";
import { ScoreCell } from "@/components/honeycomb/score-cell";
+import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item";
import type { MentionSource } from "@/lib/mention";
/** m:ss from milliseconds. */
@@ -17,7 +18,7 @@ function formatDuration(ms: number): string {
* THE song component: every place a song shows (repertoire, search results, picked songs, suggested set, songs to
* learn, learn-run results, setlists, tool cards) uses this row. A cover cell, the title and a meta line, then slots
* that take only the unified parts: `tags` (SongTag), `info` (InfoTag), `reason` (Reason), `score` (ScoreCell), `actions`
- * (SongActions). Duration lives here only. Titles and artists wrap, never truncate.
+ * (SongActions). Duration lives here only. Titles and artists wrap, never truncate. Built on shadcn Item.
* - size sm|md: 32 or 44px cover. `compact`: the agent tool cards (28px cover, small type, tight rows).
* - `index` is the set position (setlists), two digits before the cover.
* - `interactive={false}`: a combobox option. Nothing focusable inside (no link, no actions; the option is the control),
@@ -106,9 +107,9 @@ export function SongRow({
data-slot="song-row"
className={cn("@container", compact ? "py-1.5" : "py-3", muted && "opacity-60", className)}
>
-
- {/* Built with: a logo wall; each cell names the partner and its role for screen readers and on hover. */}
+ {/* Built with: a logo wall; each cell names the partner and its role for screen readers. */}
+
+
);
}
diff --git a/src/components/plan/no-pitch-panel.tsx b/src/components/plan/no-pitch-panel.tsx
index bbfe0abd..40a995b5 100644
--- a/src/components/plan/no-pitch-panel.tsx
+++ b/src/components/plan/no-pitch-panel.tsx
@@ -2,6 +2,7 @@ import Link from "next/link";
import { HexCell } from "@/components/honeycomb/hex-cell";
import { Icon } from "@/components/honeycomb/icon";
import { Button } from "@/components/ui/button";
+import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { permitName } from "@/lib/slot";
const WHO: Record = { general: "a general permit", premium: "a premium permit", "circle-act": "a circle act" };
@@ -13,38 +14,40 @@ const WHO: Record = { general: "a general permit", premium: "a p
export function NoPitchPanel({ permit, when, againHref, rulesHref }: { permit: string; when: string; againHref: string; rulesHref?: string }) {
const general = permit === "general";
return (
-
-
+
+
+
+
No pitch open for {WHO[permit] ?? permitName(permit)} {when}
-
- {general ? "The council's rules leave no pitch for this slot: try another day or time." : "The council's rules leave no pitch for this permit and slot: try a general permit or another day."}{" "}
- {rulesHref ? (
-
- Read the council's busking rules
- (opens in a new tab)
-
- ) : null}
-
-
- {general ? null : (
-
- )}
-
-
-
+ )}
+
+
+
+ Try another day
+
+
+
+
);
}
diff --git a/src/components/plan/pitch-sheet.tsx b/src/components/plan/pitch-sheet.tsx
index 7cc547f1..a6437351 100644
--- a/src/components/plan/pitch-sheet.tsx
+++ b/src/components/plan/pitch-sheet.tsx
@@ -100,7 +100,7 @@ export function PitchSheet({
repertoireTotal={repertoireTotal}
/>
-
+
diff --git a/src/components/plan/plan-view.tsx b/src/components/plan/plan-view.tsx
index 562b8d82..19b3ef60 100644
--- a/src/components/plan/plan-view.tsx
+++ b/src/components/plan/plan-view.tsx
@@ -144,7 +144,7 @@ export function PlanView({ initialPlan, heading, request, center, autoRun, examp
// The plain-AI baseline (a control: agreement is a badge, never a score change).
// Asked once the plan is shown: during the agent's run the server starts it after the agent's data is in.
const baseline = useBaseline(request, faults, revealed);
- const agreedWith = new Map(baseline.status === "done" ? baseline.baseline.bothAgree.map((a) => [a.pitchId, a.plainName]) : []);
+ const agreedWith = new Set(baseline.status === "done" ? baseline.baseline.bothAgree.map((a) => a.pitchId) : []);
const opener = useRef(null);
const openDetails = useCallback(
@@ -473,7 +473,7 @@ export function PlanView({ initialPlan, heading, request, center, autoRun, examp
<>
- {agreedWith.has(pitch.id) && }
+ {agreedWith.has(pitch.id) && }
>
}
/>
@@ -595,9 +595,9 @@ function PendingPlan() {
{[1, 2, 3].map((n) => (
-
+
-
+
))}
diff --git a/src/components/plan/qloo-down-panel.tsx b/src/components/plan/qloo-down-panel.tsx
index 853e9a2c..0d06db70 100644
--- a/src/components/plan/qloo-down-panel.tsx
+++ b/src/components/plan/qloo-down-panel.tsx
@@ -3,6 +3,7 @@ import { HexCell } from "@/components/honeycomb/hex-cell";
import { Icon } from "@/components/honeycomb/icon";
import { QlooWordmark } from "@/components/honeycomb/logo";
import { Button } from "@/components/ui/button";
+import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
/**
* Qloo is down (board Q03): no plan without taste data. The error cell, the reason in plain words, Qloo's status, and Try again,
@@ -10,24 +11,26 @@ import { Button } from "@/components/ui/button";
*/
export function QlooDownPanel({ onRetry, busy = false }: { onRetry: () => void; busy?: boolean }) {
return (
-
-
-
-
-
No plan without taste data
-
- Everything you entered is kept: try again in a minute.
-
+
+
+
+
+
+
+
+
No plan without taste data
+
+ Everything you entered is kept: try again in a minute.
-
+
Try again
-
-
+
+
);
}
diff --git a/src/components/plan/tool-results.tsx b/src/components/plan/tool-results.tsx
index 751f77e9..a173440e 100644
--- a/src/components/plan/tool-results.tsx
+++ b/src/components/plan/tool-results.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item";
import { summaryLine } from "@/lib/repertoire-research";
import { artist as mentionArtist, listOf, song as mentionSong, source as mentionSource, SPOTIFY_ARTIST_URL, type MentionSource, type RichText as RichTextData } from "@/lib/mention";
import { RichText } from "@/components/honeycomb/mention";
@@ -47,19 +48,25 @@ export type FactTone = "places" | "event" | "footfall" | "weather" | "legal";
const TONE: Record = { places: "text-chart-3", event: "text-event", footfall: "text-footfall", weather: "text-weather", legal: "text-pitch-legal" };
/**
- * One fact: an icon in a 24px column, a bold title, an optional muted line and any extras (the DS Fact). Inside a research card it is
+ * One fact (shadcn Item): an icon in a 24px column, a bold title, an optional muted line and any extras (the DS Fact). Inside a research card it is
* `flush` (no rule, no padding); in the pitch card's facts list it keeps its rule underneath.
*/
export function Fact({ icon, tone, title, sub, flush = false, children }: { icon: IconName; tone?: FactTone; title: ReactNode; sub?: ReactNode; flush?: boolean; children?: ReactNode }) {
return (
-
{R.ask.carryOn}
diff --git a/src/components/status/status-card.tsx b/src/components/status/status-card.tsx
index 6d7437c7..4fbf507d 100644
--- a/src/components/status/status-card.tsx
+++ b/src/components/status/status-card.tsx
@@ -1,11 +1,12 @@
import type { ReactNode } from "react";
import { HexCell } from "@/components/honeycomb/hex-cell";
import { Icon } from "@/components/honeycomb/icon";
+import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
/**
* The card behind every not-found and error page (site-wide 404, /p/ not found, error.tsx, global-error.tsx), so
* they match. A honeycomb cell lifted by an offset (72px: dashed with a pin for not-found, the error tone with a warning for errors; the DS StatusCard), the title in display caps, one sentence, a footnote in mono, the actions. No hooks: usable from server and
- * client components. The title is styled from the wrapper so the markup stays a plain
.
+ * client components. Built on shadcn Empty, left-aligned. The title is styled from the wrapper so the markup stays a plain