From a4e7781cca6aba90fb4f70fbf4f7fc0abf7ef59e Mon Sep 17 00:00:00 2001 From: T04435 Date: Fri, 23 Oct 2026 00:04:02 +1100 Subject: [PATCH 1/8] refactor(ui): build folds, slot picks, tags and banners on shadcn primitives Phase A of the shadcn-first pass, same look: - Disclosure, Not for me and the Repertoire research fold on Collapsible - TimeSlotPicker on RadioGroup (RadioGroupItem asChild), genre picks on ToggleGroup - Chip on Badge and Toggle, SongTag, InfoTag and the step tags on Badge variants - IconAction on Button icon variants (36px icon-dense size) - Banner on Alert banner tones, NoteCard on Card, the pending ranks on Skeleton cell Co-Authored-By: Claude Opus 5.5 --- src/components/forms/time-slot-picker.tsx | 57 ++++++--------- src/components/honeycomb/banner.tsx | 26 +++---- src/components/honeycomb/chip.tsx | 26 +++---- src/components/honeycomb/disclosure.tsx | 19 ++--- src/components/honeycomb/icon-action.tsx | 28 +++----- src/components/honeycomb/info-tag.tsx | 13 +--- src/components/honeycomb/note-card.tsx | 14 ++-- .../repertoire/repertoire-research.tsx | 72 +++++++++---------- .../repertoire/repertoire-sections.tsx | 23 +++--- src/components/honeycomb/song-tag.tsx | 14 +--- src/components/plan/agent-steps.tsx | 14 ++-- src/components/plan/pitch-sheet.tsx | 2 +- src/components/plan/plan-view.tsx | 4 +- src/components/plan/weather-strip.tsx | 5 +- src/components/playlist/research-ask.tsx | 20 ++++-- src/components/ui/alert.tsx | 19 ++++- src/components/ui/badge.tsx | 16 +++++ src/components/ui/button.tsx | 9 +++ src/components/ui/radio-group.tsx | 10 +++ src/components/ui/toggle.tsx | 19 +++-- tests/e2e/brand.spec.ts | 2 +- tests/e2e/plan-states.spec.ts | 7 +- 22 files changed, 224 insertions(+), 195 deletions(-) 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/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/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..410a5824 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,17 @@ 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}
-
+ + + {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/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-research.tsx b/src/components/honeycomb/repertoire/repertoire-research.tsx index 13e56a26..2287a17f 100644 --- a/src/components/honeycomb/repertoire/repertoire-research.tsx +++ b/src/components/honeycomb/repertoire/repertoire-research.tsx @@ -2,12 +2,13 @@ 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { RepertoireFunnel } from "./repertoire-funnel"; import type { RepertoireStep, RepertoireStepKey, RepertoireTotals, StepStatus } from "./types"; @@ -28,7 +29,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" }; @@ -58,16 +58,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} @@ -110,7 +101,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 +112,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 +143,33 @@ 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..ca629fd9 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/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/plan/agent-steps.tsx b/src/components/plan/agent-steps.tsx index cfdfc9ea..81478353 100644 --- a/src/components/plan/agent-steps.tsx +++ b/src/components/plan/agent-steps.tsx @@ -10,6 +10,7 @@ import { HexCell } from "@/components/honeycomb/hex-cell"; import { Icon } from "@/components/honeycomb/icon"; import { Loader } from "@/components/honeycomb/loader"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import type { AgentStep } from "@/lib/agent-steps"; @@ -87,7 +88,7 @@ function ToolCard({ step, model, when, pins, fresh, renderWaiting }: { step: Age {sources.map((s) => ( ))} - {waiting ? Your turn : null} + {waiting ? Your turn : null} {say.length ? ( @@ -106,8 +107,6 @@ function ToolCard({ step, model, when, pins, fresh, renderWaiting }: { step: Age ); } -const TAG = "px-1.5 py-[3px] font-mono text-[11px] leading-[normal] font-bold tracking-[0.06em] uppercase"; - /** A step not started yet (the run waits above it): its icon in a dashed cell, its title quiet, and "Not started". */ function UpcomingCard({ step }: { step: UpcomingStep }) { return ( @@ -116,7 +115,7 @@ function UpcomingCard({ step }: { step: UpcomingStep }) {
{step.title} - Not started + Not started
@@ -256,12 +255,9 @@ export function AgentStepStream({ {count} - + {tag} - + {modeLine ? ( 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..b5d3d5ed 100644 --- a/src/components/plan/plan-view.tsx +++ b/src/components/plan/plan-view.tsx @@ -595,9 +595,9 @@ function PendingPlan() {
{[1, 2, 3].map((n) => (
- + - +
))} diff --git a/src/components/plan/weather-strip.tsx b/src/components/plan/weather-strip.tsx index d30036e8..284c3b35 100644 --- a/src/components/plan/weather-strip.tsx +++ b/src/components/plan/weather-strip.tsx @@ -1,5 +1,6 @@ import { HexCell } from "@/components/honeycomb/hex-cell"; import { Icon } from "@/components/honeycomb/icon"; +import { Badge } from "@/components/ui/badge"; import { formatHour } from "@/lib/slot"; import type { Plan } from "@/lib/plan-schema"; @@ -69,9 +70,9 @@ export function WeatherStrip({ {before.map((h) => column(h, false))} {within.length ? (
- + {within.map((h, i) => column(h, true, i > 0))}
) : null} diff --git a/src/components/playlist/research-ask.tsx b/src/components/playlist/research-ask.tsx index b27f436b..64ad8817 100644 --- a/src/components/playlist/research-ask.tsx +++ b/src/components/playlist/research-ask.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { ArtistPicker, typedArtists, type PickedArtist } from "@/components/landing/artist-picker"; import { Button } from "@/components/ui/button"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { MAX_RESEARCH_GENRES } from "@/lib/repertoire-research"; import { PLAYLIST_COPY } from "@/lib/playlist-copy"; import type { ResearchAnswer } from "./learn-api"; @@ -42,17 +43,28 @@ export function ResearchAsk({ message, busy, onSubmit }: { message: string; busy }} > -
+ {R.ask.genreLabel} {R.genrePick.options.map((option) => { const on = genres.includes(option); return ( - + ); })} -
+
) } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index f8470d33..73a8cfa1 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -33,6 +33,22 @@ const badgeVariants = cva( outline: `[--tone:var(--muted-foreground)] ${tint} [a]:hover:bg-secondary`, ghost: "border-transparent text-foreground hover:bg-secondary", link: "border-transparent text-signal-text normal-case underline underline-offset-4 hover:decoration-2", + // The entity tags, each the DS look exactly (they reset the badge box above). + // Chip: a square sans label on a 1.5px rule (Chip). + chip: "min-h-8 max-w-none shrink justify-start gap-1.5 border-rule bg-transparent px-3 py-1 text-left font-sans text-sm leading-5 font-normal tracking-normal normal-case", + // Song: mono caps on a 2px rule over the card (SongTag). + song: "min-h-0 max-w-none shrink justify-start gap-1.5 border-2 border-rule bg-card px-1.5 text-left text-foreground [&>svg]:size-3!", + // Data: mono, medium, sentence case, a hairline whose colour is the tone's; the words wrap, never truncate (InfoTag). + data: "min-h-0 shrink items-start justify-start gap-1.5 border bg-transparent px-1.5 text-left font-medium tracking-normal normal-case [overflow-wrap:anywhere] [&>svg]:size-3!", + // Step tags (the research feeds): the text's own box with an inset 1.5px rule. `tag` keeps the text colour. + tag: "min-h-0 shrink border-0 px-1.5 py-[3px] leading-[normal] shadow-[inset_0_0_0_1.5px_var(--rule)]", + "tag-quiet": "min-h-0 shrink border-0 px-1.5 py-[3px] leading-[normal] text-muted-foreground shadow-[inset_0_0_0_1.5px_var(--border)]", + "tag-failed": "min-h-0 shrink border-0 px-1.5 py-[3px] leading-[normal] text-destructive shadow-[inset_0_0_0_1.5px_var(--destructive)]", + // On: the orange fill. `tag-live` alone, `tag-on` with the rule (the feed's Live). + "tag-live": "min-h-0 shrink border-0 bg-primary px-1.5 py-[3px] leading-[normal] text-primary-foreground", + "tag-on": "min-h-0 shrink border-0 bg-primary px-1.5 py-[3px] leading-[normal] text-primary-foreground shadow-[inset_0_0_0_1.5px_var(--rule)]", + // The forecast's "Your slot": the orange fill on its 2px day rule. + "tag-slot": "min-h-0 shrink border-0 bg-primary px-1.5 py-[3px] leading-[14px] whitespace-nowrap text-primary-foreground shadow-[inset_0_0_0_2px_var(--fill-rule)]", }, }, defaultVariants: { diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 41735d3f..0320ff26 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -25,6 +25,13 @@ const buttonVariants = cva( poster: "border-2 border-foreground bg-primary font-display font-extrabold tracking-[0.01em] text-primary-foreground shadow-[6px_6px_0_var(--foreground)] hover:-translate-x-px hover:-translate-y-px hover:bg-primary-hover hover:shadow-[7px_7px_0_var(--foreground)] focus-visible:outline-3 focus-visible:outline-offset-10 focus-visible:outline-ring focus-visible:outline-solid", // A link in a button's clothes: signal-text (never orange text), underlined, sentence case. + // The square icon-only actions (IconAction): no press nudge, the hover is the muted ground, a ghost turns muted when pressed. + "icon-ghost": + "cursor-pointer border-0 bg-transparent text-foreground hover:bg-muted aria-pressed:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:shadow-none active:not-aria-[haspopup]:translate-y-0 transition-none", + "icon-outline": + "cursor-pointer border-2 border-rule bg-card text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:shadow-none active:not-aria-[haspopup]:translate-y-0 transition-none", + "icon-primary": + "cursor-pointer border-2 border-rule bg-primary text-primary-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:shadow-none active:not-aria-[haspopup]:translate-y-0 transition-none", link: "focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 focus-visible:ring-0 h-auto min-h-0 border-0 px-0 font-bold tracking-normal text-signal-text normal-case underline underline-offset-4 hover:decoration-2", }, size: { @@ -40,6 +47,8 @@ const buttonVariants = cva( "icon-xs": "size-10 [&_svg:not([class*='size-'])]:size-4", "icon-sm": "size-10", "icon-lg": "size-12", + // 36px, the dense rows' icon actions (--size-control-sm). + "icon-dense": "size-control-sm", }, }, defaultVariants: { diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx index b1d4f204..9b4e80e6 100644 --- a/src/components/ui/radio-group.tsx +++ b/src/components/ui/radio-group.tsx @@ -21,8 +21,18 @@ function RadioGroup({ function RadioGroupItem({ className, + asChild, + children, ...props }: React.ComponentProps) { + // asChild: the child is the whole choice (a slot card); it draws its own picked state from aria-checked. + if (asChild) { + return ( + + {children} + + ) + } // The choice card shows the focus (.bm-choice:focus-within, in choice-cards.tsx), not the radio. // A cell inside a cell: the outer honeycomb is the choice, the orange one inside says which is picked. return ( diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx index cb451234..0c01554f 100644 --- a/src/components/ui/toggle.tsx +++ b/src/components/ui/toggle.tsx @@ -5,19 +5,30 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Toggle as TogglePrimitive } from "radix-ui" +// The shadcn toggle look (default, outline) and the Busk Map looks that reuse a toggle's behaviour: `button` is the Button's +// outline look that turns into its orange fill when on (the genre picks), `chip` is the Chip (sentence case on a 1.5px rule, +// inverted when on). Each variant carries its whole look, so `chip` and `button` match their components exactly. +const shadcnLook = + "gap-1.5 border-2 border-rule text-sm font-bold tracking-[0.03em] whitespace-nowrap uppercase transition-colors focus-visible:ring-2 focus-visible:ring-ring hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-disabled aria-invalid:border-destructive aria-pressed:bg-foreground aria-pressed:text-background data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + const toggleVariants = cva( - "group/toggle inline-flex items-center justify-center gap-1.5 rounded-none border-2 border-rule text-sm font-bold tracking-[0.03em] whitespace-nowrap uppercase transition-colors focus-visible:ring-2 focus-visible:ring-ring hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-disabled aria-invalid:border-destructive aria-pressed:bg-foreground aria-pressed:text-background data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/toggle inline-flex items-center rounded-none", { variants: { variant: { - default: "bg-transparent", - outline: "border-input bg-transparent", + default: `justify-center bg-transparent ${shadcnLook}`, + outline: `justify-center border-input bg-transparent ${shadcnLook}`, + button: + "shrink-0 justify-center border-2 border-input bg-transparent text-sm font-bold tracking-[0.03em] whitespace-nowrap text-foreground uppercase transition-colors select-none hover:bg-secondary focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 focus-visible:ring-0 active:not-aria-[haspopup]:translate-y-px disabled:cursor-not-allowed disabled:opacity-disabled aria-invalid:border-destructive data-[state=on]:border-(--fill-rule) data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:hover:bg-primary-hover [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + chip: "min-h-10 cursor-pointer gap-1.5 border-[1.5px] border-rule bg-transparent px-3 py-1 font-sans text-sm leading-5 hover:bg-secondary aria-pressed:border-foreground aria-pressed:bg-foreground aria-pressed:font-bold aria-pressed:text-background", }, size: { default: "h-control min-w-control px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", sm: "h-control-sm min-w-control-sm px-3 text-[0.8rem] [&_svg:not([class*='size-'])]:size-3.5", lg: "h-13 min-w-13 px-6 text-base", + // The Button's own sm (no minimum width): the `button` toggles sit beside Buttons. + "button-sm": "h-control-sm gap-1.5 px-3 text-[0.8rem] [&_svg:not([class*='size-'])]:size-3.5", }, }, defaultVariants: { @@ -30,7 +41,7 @@ const toggleVariants = cva( function Toggle({ className, variant = "default", - size = "default", + size = variant === "chip" ? null : "default", ...props }: React.ComponentProps & VariantProps) { diff --git a/tests/e2e/brand.spec.ts b/tests/e2e/brand.spec.ts index f5860429..9e5bdd1b 100644 --- a/tests/e2e/brand.spec.ts +++ b/tests/e2e/brand.spec.ts @@ -4,7 +4,7 @@ import { expect, test } from "./fixtures"; /** On phones the long blocks fold under closed disclosures after hydration: open them all. */ async function openAll(page: Page) { await page.waitForLoadState("networkidle"); - await page.evaluate(() => document.querySelectorAll("details").forEach((d) => (d.open = true))); + for (const fold of await page.locator("[data-slot='disclosure'] > button[aria-expanded='false']").all()) await fold.click(); } // /brand against the deck (B-070..B-079, B-152, B-154). diff --git a/tests/e2e/plan-states.spec.ts b/tests/e2e/plan-states.spec.ts index 6d8c41eb..00484cc3 100644 --- a/tests/e2e/plan-states.spec.ts +++ b/tests/e2e/plan-states.spec.ts @@ -21,8 +21,11 @@ test.describe("pitch details sheet", () => { await expect(sheet.getByTestId("pitch-card")).toBeVisible(); await expect(sheet.getByRole("region", { name: "Suggested set" })).toBeVisible(); await expect(sheet.getByRole("region", { name: "Who this crowd likes" })).toBeVisible(); - // The pitch card's ScoreBreakdown also heads itself "How this score adds up"; open the weights disclosure by its summary. - await sheet.getByTestId("score-details").locator("summary").filter({ hasText: "How this score adds up" }).click(); + // The pitch card's ScoreBreakdown also heads itself "How this score adds up"; open the weights disclosure by its trigger. + const scoreFold = sheet.getByTestId("score-details").getByRole("button", { name: "How this score adds up" }); + await expect(scoreFold).toHaveAttribute("aria-expanded", "false"); + await scoreFold.click(); + await expect(scoreFold).toHaveAttribute("aria-expanded", "true"); await expect(sheet.getByRole("region", { name: "How pitches are scored" })).toBeVisible(); await expect(sheet.getByRole("region", { name: "Estimated activity by hour" })).toBeVisible(); // Focus is inside the sheet and stays there while tabbing. From 8c2b6a5775f5d396805a775d55953ff129ecd7f2 Mon Sep 17 00:00:00 2001 From: T04435 Date: Fri, 23 Oct 2026 00:05:11 +1100 Subject: [PATCH 2/8] refactor(ui): drop native title tooltips Hover-only titles reach neither keyboard nor touch. The partner links, the demo video and the funnel cells already carry their names (sr-only text, aria-label, the cell label); the pitch card weight gains an sr-only Weight. The version badge is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/components/honeycomb/both-agree-badge.tsx | 4 ++-- src/components/honeycomb/discovery-badge.tsx | 2 +- src/components/honeycomb/repertoire/repertoire-funnel.tsx | 2 +- src/components/honeycomb/research-funnel.tsx | 2 +- src/components/honeycomb/song-actions.tsx | 2 +- src/components/landing/demo-video.tsx | 1 - src/components/landing/partners-section.tsx | 3 +-- src/components/pitch-card/pitch-card.tsx | 4 ++-- src/components/plan/plan-view.tsx | 4 ++-- tests/e2e/landing.spec.ts | 3 ++- 10 files changed, 13 insertions(+), 14 deletions(-) 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/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/repertoire/repertoire-funnel.tsx b/src/components/honeycomb/repertoire/repertoire-funnel.tsx index ee2fb79d..9eb93f3c 100644 --- a/src/components/honeycomb/repertoire/repertoire-funnel.tsx +++ b/src/components/honeycomb/repertoire/repertoire-funnel.tsx @@ -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} diff --git a/src/components/honeycomb/research-funnel.tsx b/src/components/honeycomb/research-funnel.tsx index 497bf248..0ab28609 100644 --- a/src/components/honeycomb/research-funnel.tsx +++ b/src/components/honeycomb/research-funnel.tsx @@ -67,7 +67,7 @@ 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} 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/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

    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/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/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/repertoire/repertoire-funnel.tsx b/src/components/honeycomb/repertoire/repertoire-funnel.tsx index 9eb93f3c..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. */ @@ -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 :
    ) : 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/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/performer-form.tsx b/src/components/landing/performer-form.tsx index 348903c9..7e6a860a 100644 --- a/src/components/landing/performer-form.tsx +++ b/src/components/landing/performer-form.tsx @@ -1,7 +1,7 @@ "use client"; import { Icon } from "@/components/honeycomb/icon"; -import { Loader } from "@/components/honeycomb/loader"; +import { Spinner } from "@/components/ui/spinner"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { CityPicker } from "@/components/forms/city-picker"; @@ -337,7 +337,7 @@ export function PerformerForm({ defaultDate, today, prefill, repertoire = null }
    diff --git a/src/components/landing/repertoire-picker.tsx b/src/components/landing/repertoire-picker.tsx index 0345921f..bed7e2c5 100644 --- a/src/components/landing/repertoire-picker.tsx +++ b/src/components/landing/repertoire-picker.tsx @@ -5,7 +5,7 @@ import type { TrackResult } from "@/components/forms/repertoire-api"; import { FormMessage } from "@/components/honeycomb/form-message"; import { Icon } from "@/components/honeycomb/icon"; import { IconAction } from "@/components/honeycomb/icon-action"; -import { Loader } from "@/components/honeycomb/loader"; +import { Spinner } from "@/components/ui/spinner"; import { SongActions } from "@/components/honeycomb/song-actions"; import { SongRow } from "@/components/honeycomb/song-row"; import { SongTag } from "@/components/honeycomb/song-tag"; @@ -275,7 +275,7 @@ export function RepertoirePicker({
    {loading ? ( - Loading your songs… + Loading your songs… ) : searchNote ? ( {searchNote} diff --git a/src/components/landing/song-search.tsx b/src/components/landing/song-search.tsx index 3741b2ab..6d7097df 100644 --- a/src/components/landing/song-search.tsx +++ b/src/components/landing/song-search.tsx @@ -5,7 +5,7 @@ import { searchTracks, type TrackResult } from "@/components/forms/repertoire-ap import { useLiveSearch } from "@/components/forms/use-live-search"; 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 { SongRow } from "@/components/honeycomb/song-row"; import { SPOTIFY_ARTIST_URL } from "@/lib/mention"; import { SongTag } from "@/components/honeycomb/song-tag"; @@ -164,7 +164,7 @@ export function SongSearch({ {state.status === "idle" &&

    Type {MIN_QUERY_CHARS} or more letters to search Spotify.

    } {state.status === "loading" && (

    - + Searching Spotify…

    )} diff --git a/src/components/plan/agent-steps.tsx b/src/components/plan/agent-steps.tsx index 81478353..13b2306e 100644 --- a/src/components/plan/agent-steps.tsx +++ b/src/components/plan/agent-steps.tsx @@ -8,7 +8,7 @@ import { type CSSProperties, type ReactNode } from "react"; import { cn } from "cn"; 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -189,7 +189,7 @@ export function AgentStepStream({
  • - +
    diff --git a/src/components/plan/crowd-panel.tsx b/src/components/plan/crowd-panel.tsx index 0fcd23a5..b1a0c6d1 100644 --- a/src/components/plan/crowd-panel.tsx +++ b/src/components/plan/crowd-panel.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; import { SourceLogo } from "@/components/honeycomb/source-logo"; import { ArtistChip } from "@/components/honeycomb/artist-chip"; import { Chip } from "@/components/honeycomb/chip"; @@ -76,15 +77,19 @@ export function CrowdPanel({ {empty && ( -
    - - - -

    No crowd data for this plan

    -

    + + + + + + + +

    No crowd data for this plan

    + + {resolved.length > 0 && unmatched.length === resolved.length ? "Qloo matched none of your artists, so the pitches are ranked on events and weather." : "The pitches are ranked on events and weather only."} -

    -
    + + )} {crowd.drivers.length > 0 && ( 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 : ( - - )} - -
    -
    -
    + )} + + + ); } 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. -
    + -
    -
    + + ); } 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 ( -
    - -
    -
    {title}
    - {sub ?
    {sub}
    : null} + + + + + + {title} + {sub ? ( + +
    {sub}
    +
    + ) : null} {children} -
    -
    + + ); } @@ -74,10 +81,14 @@ export function Cautions({ items }: { items: string[] }) { return (
      {items.map((c) => ( -
    • - - {c} -
    • + +
    • + + + + {c} +
    • +
      ))}
    ); diff --git a/src/components/plan/weather-strip.tsx b/src/components/plan/weather-strip.tsx index 284c3b35..466e3119 100644 --- a/src/components/plan/weather-strip.tsx +++ b/src/components/plan/weather-strip.tsx @@ -1,6 +1,7 @@ import { HexCell } from "@/components/honeycomb/hex-cell"; import { Icon } from "@/components/honeycomb/icon"; import { Badge } from "@/components/ui/badge"; +import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"; import { formatHour } from "@/lib/slot"; import type { Plan } from "@/lib/plan-schema"; @@ -21,15 +22,19 @@ export function WeatherStrip({ }) { if (!weather) return ( -
    - - - -
    -
    No forecast for this slot
    -
    Weather is left out of the score.
    -
    -
    + + + + + + + + No forecast for this slot + +
    Weather is left out of the score.
    +
    +
    +
    ); const inSlot = (hour: number) => hour >= slot.startHour && hour < slot.endHour; const wet = weather.hours.filter((h) => inSlot(h.hour) && (h.precipitationProbability ?? 0) >= 50).length; diff --git a/src/components/playlist/learn-feed.tsx b/src/components/playlist/learn-feed.tsx index 5c21befd..57d7cce9 100644 --- a/src/components/playlist/learn-feed.tsx +++ b/src/components/playlist/learn-feed.tsx @@ -3,7 +3,7 @@ import { Fragment } from "react"; 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 { LearnLive } from "@/lib/learn-live"; import { funnelOf } from "@/lib/learn-plain"; import { PLAYLIST_COPY } from "@/lib/playlist-copy"; @@ -42,7 +42,7 @@ export function ResearchFunnel({ kind, totals, running }: { kind: "learn"; total > {choosing ? ( <> - + choosing songs ) : ( 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

    . */ export function StatusCard({ title, @@ -39,20 +40,24 @@ export function StatusCard({ {numeral}

    ) : null} -
    - - - - {title} -

    {description}

    + + + + + + + {title} + + {description} {footnote ?

    {footnote}

    : null} -
    {actions}
    -
    + {actions} + ); } diff --git a/src/components/ui/empty.tsx b/src/components/ui/empty.tsx new file mode 100644 index 00000000..737b1cd4 --- /dev/null +++ b/src/components/ui/empty.tsx @@ -0,0 +1,102 @@ +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +// Honeycomb empty states (the DS empty): a centred column of a lifted HexCell, a display-caps heading, one muted line and the +// ways out. The title is a div: nest the real heading (h1-h4, with the DS heading classes) so the outline holds. +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +const emptyMediaVariants = cva( + "flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "", + icon: "", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
    + ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

    + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +

    + ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} diff --git a/src/components/ui/item.tsx b/src/components/ui/item.tsx new file mode 100644 index 00000000..d63069d3 --- /dev/null +++ b/src/components/ui/item.tsx @@ -0,0 +1,193 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" +import { Slot } from "radix-ui" + +import { Separator } from "@/components/ui/separator" + +function ItemGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function ItemSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +// Honeycomb rows: square, no box and no padding of their own (the list's rules and the row's own spacing set them apart), the +// text size inherited. `outline` is the DS dashed unavailable row. Rows never truncate: the title and description wrap. +const itemVariants = cva( + "group/item flex flex-wrap items-center rounded-none", + { + variants: { + variant: { + default: "", + outline: "border-[1.5px] border-dashed border-input", + muted: "bg-muted", + }, + size: { + default: "gap-3", + sm: "gap-2.5", + xs: "gap-2", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Item({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"div"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "div" + return ( + + ) +} + +// The media is a HexCell or a DS Icon at its own size: no box around it. +const itemMediaVariants = cva( + "flex shrink-0 items-center justify-center [&_svg]:pointer-events-none", + { + variants: { + variant: { + default: "", + icon: "", + image: "overflow-hidden [&_img]:size-full [&_img]:object-cover", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function ItemMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
    + ) +} + +function ItemContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +// No line-clamp (DS rule: never truncate). `asChild` keeps the caller's element (a p, a heading). +function ItemTitle({ className, asChild = false, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "div" + return ( + + ) +} + +// No line-clamp (DS rule: never truncate). +function ItemDescription({ className, asChild = false, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "p" + return ( + + ) +} + +function ItemActions({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function ItemHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function ItemFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +export { + Item, + ItemMedia, + ItemContent, + ItemActions, + ItemGroup, + ItemSeparator, + ItemTitle, + ItemDescription, + ItemHeader, + ItemFooter, +} diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx new file mode 100644 index 00000000..22b318bf --- /dev/null +++ b/src/components/ui/spinner.tsx @@ -0,0 +1,44 @@ +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) + +/** + * shadcn Spinner, drawing the DS loader: the pitch ring turns, the performer's cell stays put (ported 1:1 from the DS Loader). 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; inside a labelled cell pass `aria-hidden` and let the cell speak. + */ +function Spinner({ + size = 24, + label, + className, + ...props +}: Omit, "children"> & { size?: number; label?: string }) { + return ( + + + + ) +} + +export { Spinner } diff --git a/tests/unit/components/honeycomb.test.ts b/tests/unit/components/honeycomb.test.ts index b47381c3..f6ec85a2 100644 --- a/tests/unit/components/honeycomb.test.ts +++ b/tests/unit/components/honeycomb.test.ts @@ -6,7 +6,7 @@ import { HexCell, cellHeight, initialsOf } from "@/components/honeycomb/hex-cell import { leanText } from "@/components/honeycomb/lean-bar"; import { MobileNavView } from "@/components/honeycomb/mobile-nav"; import { activeNavId, NAV_ITEMS } from "@/components/honeycomb/nav-items"; -import { Loader } from "@/components/honeycomb/loader"; +import { Spinner } from "@/components/ui/spinner"; import { MeterBar, ScoreCell, scoreLabel } from "@/components/honeycomb/score-cell"; import { RankTile } from "@/components/honeycomb/rank-tile"; @@ -66,10 +66,10 @@ describe("MeterBar", () => { }); }); -describe("Loader", () => { +describe("Spinner (the DS loader)", () => { it("is the DS ring plus a still hex core, a status named 'Loading' by default", () => { - const out = html(createElement(Loader, {})); - expect(out).toContain(''); + const out = html(createElement(Spinner, {})); + expect(out).toContain(''); expect(out).toContain('width="24" height="24" viewBox="0 0 48 48"'); expect(out).toContain(' { }); it("keeps size, label and className", () => { - const out = html(createElement(Loader, { size: 14, label: "Working", className: "bm-loader-think" })); + const out = html(createElement(Spinner, { size: 14, label: "Working", className: "bm-loader-think" })); expect(out).toContain('class="bm-loader bm-loader-think"'); expect(out).toContain('aria-label="Working"'); expect(out).toContain('width="14" height="14"'); From 33cd4afa540fa5da5c615a6b37ff4eed653374ff Mon Sep 17 00:00:00 2001 From: T04435 Date: Fri, 23 Oct 2026 00:14:46 +1100 Subject: [PATCH 4/8] feat(ui): research feed on shadcn Trace Phase C. Installed trace (collapsible kept as vendored) and restyled it for the Honeycomb feed: plain lines only, no folds (U22, no aria-expanded), no truncation, the step cell as TraceIcon with its own label. The busk plan's AgentStepStream and the Repertoire StepCard both build on Trace, TraceLine, TraceIcon and TraceTitle; the agent-step test ids and data-tool/data-status stay on each step. Repeated stream events for one step id now show one row. Co-Authored-By: Claude Opus 5.5 --- .../repertoire/repertoire-research.tsx | 31 ++++--- src/components/plan/agent-steps.tsx | 65 ++++++++++----- src/components/ui/trace.tsx | 80 +++++++++++++++++++ 3 files changed, 145 insertions(+), 31 deletions(-) create mode 100644 src/components/ui/trace.tsx diff --git a/src/components/honeycomb/repertoire/repertoire-research.tsx b/src/components/honeycomb/repertoire/repertoire-research.tsx index 2287a17f..826443e9 100644 --- a/src/components/honeycomb/repertoire/repertoire-research.tsx +++ b/src/components/honeycomb/repertoire/repertoire-research.tsx @@ -8,6 +8,7 @@ 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"; @@ -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 ( -
  • - +
  • + + +
    -

    {info.title}

    + +

    {info.title}

    +
    {sources.length > 0 ? ( {sources.map((src) => ( @@ -76,6 +82,7 @@ function StepCard({ info, step, n }: { info: RepertoireStepInfo; step: Repertoir ) : null}
  • + ); } @@ -163,11 +170,13 @@ export function RepertoireResearch({
    -
      - {REPERTOIRE_STEPS.map((info, i) => ( - - ))} -
    + +
      + {REPERTOIRE_STEPS.map((info, i) => ( + + ))} +
    +
    diff --git a/src/components/plan/agent-steps.tsx b/src/components/plan/agent-steps.tsx index 13b2306e..4d2d7e9b 100644 --- a/src/components/plan/agent-steps.tsx +++ b/src/components/plan/agent-steps.tsx @@ -11,6 +11,7 @@ import { Icon } from "@/components/honeycomb/icon"; import { Spinner } from "@/components/ui/spinner"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; +import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import type { AgentStep } from "@/lib/agent-steps"; @@ -20,7 +21,7 @@ import { stepSources, TOOL_ICON, TOOL_UI, type MiniPin } from "./tool-results"; /** * The research feed: the agent's steps as the busker sees them, in the order they ran (U23), no step numbers, each tool drawn as a plain-words - * result card from its output fields. Ported from the DS AgentStepStream. + * result card from its output fields. Ported from the DS AgentStepStream, on shadcn Trace (plain lines, never folded: U22). */ export { applyStreamEvent, formatInput, type AgentStep, type RunUsage, type StepNote } from "@/lib/agent-steps"; @@ -40,6 +41,8 @@ const Say = ({ model, children, testId }: { model: ExplanationsModel | null; chi /** Each card: its step cell (HIVE_SIZES.stepCell, the running Hive mark inside) and the title column; the body lines up under the title from sm up. */ const STEP_COLS = { gridTemplateColumns: `${HIVE_SIZES.stepCell}px minmax(0,1fr)` } as const; +/** Each step's header row (a Trace line): the cell column and the title column, top-aligned. */ +const HEAD = "grid min-w-auto items-start gap-3"; const STEP_INDENT = { "--step-indent": `${HIVE_SIZES.stepCell + 12}px` } as CSSProperties; function ToolCard({ step, model, when, pins, fresh, renderWaiting }: { step: AgentStep; model: ExplanationsModel | null; when?: string; pins?: MiniPin[]; fresh: boolean; renderWaiting?: (step: AgentStep) => ReactNode }) { @@ -80,11 +83,16 @@ function ToolCard({ step, model, when, pins, fresh, renderWaiting }: { step: Age className={cn("grid min-w-0 gap-3 py-4 first:pt-0 last:pb-0", failed && "border-l-2 border-destructive pl-3")} data-fresh={fresh && !running ? "true" : undefined} > -
    - + +
    + + +
    - {title} + + {title} + {sources.map((s) => ( ))} @@ -97,7 +105,8 @@ function ToolCard({ step, model, when, pins, fresh, renderWaiting }: { step: Age ) : null}
    {rejected ? "rejected" : waiting ? "waiting for you" : step.status} -
    +
    +
    {body ? (
    {body} @@ -111,13 +120,19 @@ function ToolCard({ step, model, when, pins, fresh, renderWaiting }: { step: Age function UpcomingCard({ step }: { step: UpcomingStep }) { return (
  • -
    - -
    - {step.title} - Not started + +
    + + + +
    + + {step.title} + + Not started +
    -
    +
  • ); } @@ -174,25 +189,32 @@ export function AgentStepStream({ const calls = steps.filter((s) => s.tool).map((s) => (!live && s.status === "running" ? { ...s, status: "cut" as const } : s)); const pending = steps.length > 0 && steps[steps.length - 1].tool === null ? steps[steps.length - 1] : null; // U23: run order, step 1 at the top; only finished steps and the one running now (no rows for steps not yet started). - const feed = calls; + // Stream events can repeat a step: one row per id (its latest state, in first-seen order). + const latest = new Map(calls.map((s) => [s.id, s])); + const feed = [...latest.values()]; const thinking = live && (pending || phase === "thinking" || steps.length === 0); - const count = `${calls.length} ${calls.length === 1 ? "check" : "checks"}${seconds != null ? ` · ${Math.round(seconds)} s` : ""}`; + const count = `${feed.length} ${feed.length === 1 ? "check" : "checks"}${seconds != null ? ` · ${Math.round(seconds)} s` : ""}`; const tag = phase === "waiting" ? "Waiting" : mode === "replay" ? "Replay" : mode === "joined" ? "Joined" : phase === "done" || phase === "fallback" ? "Finished" : phase === "error" || phase === "stopped" ? "Stopped" : "Live"; const heading = title ?? (live ? "Finding your best pitches" : "How we found your pitches"); const list = ( -
      + +
        {feed.map((s, i) => ( ))} {phase === "waiting" ? (upcoming ?? []).map((u) => ) : null} {thinking ? (
      1. -
        - - - + +
        + + + + +
        - + + {steps.length === 0 ? (wording?.first ?? "Reading your act, day and permit") : (wording?.next ?? "Thinking about what to check next")} + {pending ? {pending.notes.map((n) => n.text).join(" ")} : null}
        -
        +
        +
      2. ) : null}
      +
      ); const retry = onRetry ? (
      diff --git a/src/components/ui/trace.tsx b/src/components/ui/trace.tsx new file mode 100644 index 00000000..6b143e3c --- /dev/null +++ b/src/components/ui/trace.tsx @@ -0,0 +1,80 @@ +import * as React from "react" +import { cn } from "cn" +import { Slot } from "radix-ui" + +// shadcn Trace, restyled for the Honeycomb research feed (U22): every step is a plain line, always shown, never behind a fold, +// so the collapsible parts are left out. No box, no truncation (titles wrap), no colour or opacity per status: the step cell +// says the state. `data-status` (running, error, pending) stays on the line for styling and tests. `asChild` keeps the +// caller's element (the feed's ol, each step's header row, a heading-weight title). + +function Trace({ className, asChild = false, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "div" + return ( + + ) +} + +function TraceLine({ + className, + status, + asChild = false, + ...props +}: React.ComponentProps<"span"> & { + status?: "running" | "error" | "pending" + asChild?: boolean +}) { + const Comp = asChild ? Slot.Root : "span" + return ( + + ) +} + +/** The step's cell. Decoration by default; pass `aria-hidden={undefined}` when the cell carries the step's label. */ +function TraceIcon({ className, ...props }: React.ComponentProps<"span">) { + return ( +