Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion components.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
"registries": {
"@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json"
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/app/brand/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -256,7 +256,7 @@ export default function BrandPage() {
<ScoreCell value={91} size="lg" label="Score" />
<ScoreCell value={64} size="lg" label="Taste match" />
<ScoreCell value={null} size="lg" label="Score" />
<span className="inline-flex items-center gap-2 text-sm"><HexCell size={56} tone="dashed"><Loader size={22} label="Working" /></HexCell>Working</span>
<span className="inline-flex items-center gap-2 text-sm"><HexCell size={56} tone="dashed"><Spinner size={22} label="Working" /></HexCell>Working</span>
<RankTile n={2} />
</div>
</PhoneFold>
Expand Down
139 changes: 139 additions & 0 deletions src/components/ai-elements/plan.tsx
Original file line number Diff line number Diff line change
@@ -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<PlanContextValue | null>(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<typeof Collapsible> & {
isStreaming?: boolean;
};

export const Plan = ({
className,
isStreaming = false,
children,
...props
}: PlanProps) => {
const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);

return (
<PlanContext.Provider value={contextValue}>
<Collapsible asChild data-slot="plan" {...props}>
<Card className={cn("shadow-none", className)}>{children}</Card>
</Collapsible>
</PlanContext.Provider>
);
};

export type PlanHeaderProps = ComponentProps<typeof CardHeader>;

export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
<CardHeader
className={cn("flex items-start justify-between", className)}
data-slot="plan-header"
{...props}
/>
);

export type PlanTitleProps = ComponentProps<"div">;

export const PlanTitle = ({ className, ...props }: PlanTitleProps) => {
const { isStreaming } = usePlan();

return (
<div
className={cn("min-w-0", isStreaming && streamingText, className)}
data-slot="plan-title"
{...props}
/>
);
};

export type PlanDescriptionProps = ComponentProps<typeof CardDescription>;

export const PlanDescription = ({
className,
...props
}: PlanDescriptionProps) => {
const { isStreaming } = usePlan();

return (
<CardDescription
className={cn("text-balance", isStreaming && streamingText, className)}
data-slot="plan-description"
{...props}
/>
);
};

export type PlanActionProps = ComponentProps<typeof CardAction>;

export const PlanAction = (props: PlanActionProps) => (
<CardAction data-slot="plan-action" {...props} />
);

export type PlanContentProps = ComponentProps<typeof CardContent>;

export const PlanContent = (props: PlanContentProps) => (
<CollapsibleContent asChild>
<CardContent data-slot="plan-content" {...props} />
</CollapsibleContent>
);

export type PlanFooterProps = ComponentProps<"div">;

export const PlanFooter = (props: PlanFooterProps) => (
<CardFooter data-slot="plan-footer" {...props} />
);

export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;

export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
<CollapsibleTrigger asChild>
<Button
className={cn("size-8", className)}
data-slot="plan-trigger"
size="icon"
variant="ghost"
{...props}
>
<Icon name="chevron-down" size={16} />
<span className="sr-only">Toggle plan</span>
</Button>
</CollapsibleTrigger>
);
80 changes: 80 additions & 0 deletions src/components/ai-elements/sources.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Collapsible>;

export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
data-slot="sources"
className={cn("not-prose", className)}
{...props}
/>
);

export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number;
};

export const SourcesTrigger = ({
className,
count,
children,
...props
}: SourcesTriggerProps) => (
<CollapsibleTrigger
data-slot="sources-trigger"
className={cn("flex items-center gap-2", className)}
{...props}
>
{children ?? (
<>
<span className="font-medium">Used {count} sources</span>
<Icon name="chevron-down" size={16} />
</>
)}
</CollapsibleTrigger>
);

export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;

export const SourcesContent = ({
className,
...props
}: SourcesContentProps) => (
<CollapsibleContent
data-slot="sources-content"
className={cn("outline-none", className)}
{...props}
/>
);

export type SourceProps = ComponentProps<"a">;

export const Source = ({ href, title, children, className, ...props }: SourceProps) => (
<a
data-slot="source"
className={cn("inline-flex items-center gap-2", className)}
href={href}
rel="noreferrer"
target="_blank"
{...props}
>
{children ?? (
<>
<Icon name="external" size={16} />
<span className="block font-medium">{title}</span>
</>
)}
</a>
);
4 changes: 2 additions & 2 deletions src/components/brand/brand-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -187,7 +187,7 @@ export function PlanStates() {
<div className={SPEC}>
<h4 className={SPEC_H}>Loader · Numbers</h4>
<div className="flex flex-wrap items-center gap-4">
<HexCell size={56} tone="dashed"><Loader size={22} label="Working" /></HexCell>
<HexCell size={56} tone="dashed"><Spinner size={22} label="Working" /></HexCell>
<ScoreCell value={91} size="lg" label="Score" tone="primary" />
<ScoreCell value={64} size="md" label="Taste match" />
<ScoreCell value={null} size="md" label="Score" />
Expand Down
57 changes: 23 additions & 34 deletions src/components/forms/time-slot-picker.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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.
Expand All @@ -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<HTMLButtonElement>, 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 (
<div className="flex min-w-0 flex-col gap-1.5" data-slot="time-slot-picker" data-testid="time-slot-picker">
<span id={labelId} className="text-[15px] leading-5 font-bold text-foreground">
{label}
</span>
<div role="radiogroup" aria-labelledby={labelId} aria-describedby={describedBy} className="grid grid-cols-[repeat(auto-fit,minmax(96px,1fr))] gap-1.5">
{options.map((o, i) => (
<button
key={o.id}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={value === o.id}
tabIndex={tabIndexOf(i)}
data-testid={o.id === "any" ? "time-any" : o.id === "custom" ? "time-custom" : undefined}
onClick={() => onChange(o.id)}
onKeyDown={(e) => onKeyDown(e, i)}
className={cn(SLOT, (o.id === "any" || o.id === "custom") && "border-dashed")}
>
<b className="block font-display text-lg leading-5 font-extrabold tracking-[0.02em] uppercase">{o.label}</b>
<span className={cn("block font-mono text-[13px] leading-[18px]", value === o.id ? "text-primary-foreground" : "text-muted-foreground")}>{o.range}</span>
</button>
<RadioGroup
value={value}
onValueChange={(v) => 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) => (
<RadioGroupItem key={o.id} value={o.id} asChild>
<button
type="button"
data-testid={o.id === "any" ? "time-any" : o.id === "custom" ? "time-custom" : undefined}
className={cn(SLOT, (o.id === "any" || o.id === "custom") && "border-dashed")}
>
<b className="block font-display text-lg leading-5 font-extrabold tracking-[0.02em] uppercase">{o.label}</b>
<span className={cn("block font-mono text-[13px] leading-[18px]", value === o.id ? "text-primary-foreground" : "text-muted-foreground")}>{o.range}</span>
</button>
</RadioGroupItem>
))}
</div>
</RadioGroup>
{anyTime && value === "any" ? (
<p className="m-0 text-sm leading-5 text-muted-foreground" data-testid="any-time-note">
{anyHint}
Expand Down
Loading
Loading