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 (
{label} -
- {options.map((o, i) => ( - + onChange(v as TimeSlotId)} + loop + aria-labelledby={labelId} + aria-describedby={describedBy} + className="grid grid-cols-[repeat(auto-fit,minmax(96px,1fr))] gap-1.5" + > + {options.map((o) => ( + + + ))} -
+ {anyTime && value === "any" ? (

{anyHint} diff --git a/src/components/honeycomb/artist-chip.tsx b/src/components/honeycomb/artist-chip.tsx index 5dd9f260..8b0f30d4 100644 --- a/src/components/honeycomb/artist-chip.tsx +++ b/src/components/honeycomb/artist-chip.tsx @@ -4,6 +4,7 @@ import { HexCell, initialsOf } from "@/components/honeycomb/hex-cell"; import { IconAction } from "@/components/honeycomb/icon-action"; import { Icon } from "@/components/honeycomb/icon"; import { SongTag } from "@/components/honeycomb/song-tag"; +import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"; const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:shadow-none"; @@ -91,27 +92,33 @@ export function ArtistChip({ const cell = size === "lg" ? 48 : 36; const hasAction = state === "suggested" || state === "picked"; return ( -

+
- - {initialsOf(name)} - -
+ + + {initialsOf(name)} + + +
-

{nameEl(nameCls)}

- {meta ?

{meta}

: null} + +

{nameEl(nameCls)}

+
+ {meta ? ( + {meta} + ) : null}
{info ?
{info}
: null} {children} -
+ {score ?
{score}
: null}
{state === "added" ? ( -
+ -
+ ) : hasAction ? ( -
+ {interactive ? ( suggested ? ( @@ -123,9 +130,9 @@ export function ArtistChip({ )} -
+ ) : null} -
+ ); } 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 ? : 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 ? : 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 ( -

- {tone === "error" ? : null} - {tone === "success" ? : null} + {icon} {children} -

+ ); } 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 :
  • - +
  • + + +
    -

    {info.title}

    + +

    {info.title}

    +
    {sources.length > 0 ? ( {sources.map((src) => ( @@ -58,16 +64,7 @@ function StepCard({ info, step, n }: { info: RepertoireStepInfo; step: Repertoir ))} ) : null} - {tag ? ( - - {tag} - - ) : null} + {tag ? {tag} : null}
    {status === "done" && step?.found ?

    {step.found}

    : null} {(status === "failed" || status === "skipped") && step?.note ?

    {step.note}

    : null} @@ -85,6 +82,7 @@ function StepCard({ info, step, n }: { info: RepertoireStepInfo; step: Repertoir ) : null}
  • + ); } @@ -110,7 +108,6 @@ export function RepertoireResearch({ onToggleFold?: () => void; className?: string; }) { - const bodyId = useId(); const running = phase === "running"; const done = phase === "done"; const byKey = new Map(steps.map((s) => [s.key, s])); @@ -122,7 +119,7 @@ export function RepertoireResearch({ const isFolded = canFold && !!folded; const tag = running ? "Live" : done ? "Finished" : "Stopped"; - const tagEl = {tag}; + const tagEl = {tag}; // While it runs, the lockup is the heading ("The Hive is researching your act"), so the Live tag rides on the meta line. const title = running ? null : ( @@ -153,31 +150,35 @@ export function RepertoireResearch({ ); return ( -
    - {canFold ? ( - - ) : ( -
    {header}
    - )} - -
    + { + if (canFold && open === isFolded) onToggleFold?.(); + }} + > +
    + {canFold ? ( + + {header} + + + ) : ( +
    {header}
    + )} + +
    + +
    + +
      + {REPERTOIRE_STEPS.map((info, i) => ( + + ))} +
    +
    +
    +
    +
    ); } diff --git a/src/components/honeycomb/repertoire/repertoire-sections.tsx b/src/components/honeycomb/repertoire/repertoire-sections.tsx index be0d7bfe..ace3bfee 100644 --- a/src/components/honeycomb/repertoire/repertoire-sections.tsx +++ b/src/components/honeycomb/repertoire/repertoire-sections.tsx @@ -11,6 +11,7 @@ import { Reason } from "@/components/honeycomb/reason"; import { SongActions } from "@/components/honeycomb/song-actions"; import { SongRow } from "@/components/honeycomb/song-row"; import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import type { FanLean, LearnSong, ResearchArtist, ResearchSong } from "./types"; @@ -334,17 +335,19 @@ export function ToLearnSection({ ) : null} {dismissed.length > 0 ? ( -
    - + + Not for me ({dismissed.length}) - - -
      - {dismissed.map((s) => ( - - ))} -
    -
    + + + +
      + {dismissed.map((s) => ( + + ))} +
    +
    + ) : null} ) : null} diff --git a/src/components/honeycomb/research-funnel.tsx b/src/components/honeycomb/research-funnel.tsx index 497bf248..184b5b26 100644 --- a/src/components/honeycomb/research-funnel.tsx +++ b/src/components/honeycomb/research-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 { ResearchTotals } from "@/lib/research-totals"; /** The DS formatCount: 1.2K, 12K, 3.4M. Counts under 1000 stay as they are. */ @@ -67,9 +67,9 @@ export function ResearchFunnel({ const text = fmt(n); const name = n === 1 ? s.one : s.many; items.push( -
  • +
  • 3 ? 10 : 12} className={CELL} label={busy ? `${s.many}: checking` : `${n} ${name} (${s.src})`}> - {busy ? : text} + {busy ? {name}
  • , @@ -87,7 +87,7 @@ export function ResearchFunnel({ link("total"),
  • 3 ? 10 : 12} className={CELL} label={done ? `${picks} pitches for your busk` : "Choosing your pitches"}> - {done ? picks : } + {done ? picks : {done ? "pitches" : "choosing pitches"}
  • , 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. + + +
    -
    -
    + + ); + return ( + +
    + + + + + + + {slotLabel} + +

    This slot has passed

    +
    + 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)} > -
    ) : null} - - - -
    -

    - {href && interactive ? ( - - {title} - (opens Spotify) - - ) : ( - title - )} -

    -
    - {who.length > 0 ? ( - // The artists as inline mentions (U14): hex photo plus the name in brand orange, the same as in a reason. - - {who.map((a, i) => ( - - {i > 0 ? ", " : null} - - - ))} - - ) : null} - {meta ? {who.length > 0 ? `· ${meta}` : meta} : null} - {source ? : null} - {tags} - {durationMs != null ? ( - {formatDuration(durationMs)} - ) : null} -
    + + + + + + + +

    + {href && interactive ? ( + + {title} + (opens Spotify) + + ) : ( + title + )} +

    +
    + +
    + {who.length > 0 ? ( + // The artists as inline mentions (U14): hex photo plus the name in brand orange, the same as in a reason. + + {who.map((a, i) => ( + + {i > 0 ? ", " : null} + + + ))} + + ) : null} + {meta ? {who.length > 0 ? `· ${meta}` : meta} : null} + {source ? : null} + {tags} + {durationMs != null ? ( + {formatDuration(durationMs)} + ) : null} +
    +
    {info ?
    {info}
    : null} {reason ?
    {reason}
    : null} {children ?
    {children}
    : null} -
    + {hasRight ? ( // The score and an icon or two stay at the right on every width; more than two wrap under the text on a narrow row. -
    {hasScore ? : null} {showActions ? actions : null} -
    + ) : null} -
    + ); } diff --git a/src/components/honeycomb/song-tag.tsx b/src/components/honeycomb/song-tag.tsx index d2163925..46111b9e 100644 --- a/src/components/honeycomb/song-tag.tsx +++ b/src/components/honeycomb/song-tag.tsx @@ -1,5 +1,5 @@ -import { cn } from "cn"; import { Icon } from "@/components/honeycomb/icon"; +import { Badge } from "@/components/ui/badge"; export type SongTagKind = "typed" | "suggested" | "learned" | "by-your-artist" | "added"; @@ -26,17 +26,9 @@ export function SongTag({ }) { const k = KINDS[kind]; return ( - + {k.icon ? : null} {label ?? k.label} - + ); } diff --git a/src/components/landing/artist-picker.tsx b/src/components/landing/artist-picker.tsx index 29cda541..cb5932a3 100644 --- a/src/components/landing/artist-picker.tsx +++ b/src/components/landing/artist-picker.tsx @@ -6,7 +6,7 @@ import { loadListeners, loadReleases, searchArtists, type ArtistResult } from "@ import { useLiveSearch } from "@/components/forms/use-live-search"; import { ArtistChip } from "@/components/honeycomb/artist-chip"; import { Icon } from "@/components/honeycomb/icon"; -import { Loader } from "@/components/honeycomb/loader"; +import { Spinner } from "@/components/ui/spinner"; import { SourceLogo } from "@/components/honeycomb/source-logo"; import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { formatCount, MIN_QUERY_CHARS } from "@/lib/live-search"; @@ -162,7 +162,7 @@ export function ArtistPicker({ value, onChange, query, onQueryChange, hideLabel )} {state.status === "loading" && (

    - + Searching Spotify…

    )} diff --git a/src/components/landing/demo-video.tsx b/src/components/landing/demo-video.tsx index e3ff6287..03fea7e7 100644 --- a/src/components/landing/demo-video.tsx +++ b/src/components/landing/demo-video.tsx @@ -40,7 +40,6 @@ export function DemoVideo() { width={1280} height={720} poster="/video/busk-map-demo-poster.jpg" - title="Busk Map demo" aria-label="Busk Map demo video, 1 min 28 s, with captions" className="block aspect-video max-h-[720px] w-full bg-background object-contain" > diff --git a/src/components/landing/partners-section.tsx b/src/components/landing/partners-section.tsx index e450e140..e3fa2c3a 100644 --- a/src/components/landing/partners-section.tsx +++ b/src/components/landing/partners-section.tsx @@ -87,7 +87,7 @@ export function PartnersSection({ partners = BUILT_WITH }: { partners?: Partner[
    - {/* 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. */}

    Built with