feat: initial commit — crema-app-aifirst-template

Hybrid traditional + AI-first webapp scaffold. Sibling to crema-app-template,
adds the AI assistant surface, command bus, scripts dialog, and virtual
cursor.

What's pre-wired:
- 6 routes: Overview, Resources, Activity, Assistant, Library, Settings
- Collapsible rail + appbar + avatar dropdown shell (template code, not a lib)
- Mobile sheet at <md
- /assistant: streaming chat via @crema/llm-ui, mock fallback, model selector,
  token meter, retry probe, stop-while-streaming, persistent UI Control toggle
- /settings: editable LM Studio endpoint + context window + response cap, with
  test-connection button
- Markdown rendering for assistant replies; ```action``` blocks rendered as a
  small "Ran N actions" pill
- ⌘⇧P script runner dialog + Play icon in the appbar
- Two demo scripts in public/scripts/
- mightypix theme as default, scoped via <AppShell theme="mightypix">

Libs wired in tsconfig + app.css:
- @crema/action-bus (the bus, parser, runner, cursor, provider, ws, llm-bridge)
- @crema/llm-ui, @crema/chat-ui, @crema/aifirst-ui, @crema/notification-ui
- lib-theme-mightypix

Docs:
- README.md — pitch + quick start + structure
- docs/AI_FIRST.md — full system tour (data-action contract, bus, DSL, scripts,
  cursor, LLM integration)
- app/components/layout/THEME_CONTRACT.md — every CSS variable a theme must declare
- CLAUDE.md — orientation for an LLM working in the repo

Genericized from comfy-cloud (the original prototype):
- Brand defaults to "App" / Sparkles icon (override via app/lib/identity.ts)
- User defaults to a stub (swap useUser() for real auth)
- localStorage namespace is "crema.*" (was "comfy.*")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-04-27 18:31:46 +10:00
commit 286e2daf95
88 changed files with 16464 additions and 0 deletions

43
app/routes/activity.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { Activity } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Activity")
export default function ActivityRoute() {
return (
<AppShell title="Activity">
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>
Event stream, audit log, recent changes.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
<Activity className="size-6" />
</div>
<div className="max-w-md">
<p className="font-medium">No activity yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Once your app is doing things, this is where audit events,
webhook deliveries, and recent changes show up pair with{" "}
<code className="font-mono text-xs">@crema/log-ui</code>.
</p>
</div>
</div>
</CardContent>
</Card>
</AppShell>
)
}

441
app/routes/assistant.tsx Normal file
View File

@@ -0,0 +1,441 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { RefreshCw, Square } from "lucide-react"
const PROBE_TIMEOUT_MS = 3000
function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const t = setTimeout(() => {
reject(new Error(`timeout after ${ms}ms`))
}, ms)
signal.addEventListener("abort", () => {
clearTimeout(t)
reject(new DOMException("Aborted", "AbortError"))
})
p.then(
(v) => {
clearTimeout(t)
resolve(v)
},
(e) => {
clearTimeout(t)
reject(e)
},
)
})
}
import {
LLMProvider,
MockLLM,
OpenAICompatibleAdapter,
listModels,
useChat,
type LLMAdapter,
} from "@crema/llm-ui"
import { ChatBubble, TypingIndicator } from "@crema/chat-ui"
import { CommandBar } from "@crema/aifirst-ui"
import { AppShell } from "~/components/layout/app-shell"
import { MessageBody } from "~/components/assistant/message-body"
import { Button } from "~/components/ui/button"
import {
buildSystemPrompt,
estimateTokens,
runActionBlocks,
trimMessages,
} from "@crema/action-bus"
import { useLLMSettings } from "~/lib/llm-settings"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Assistant")
const STORAGE_KEY = "crema.assistant.model"
const UI_CONTROL_KEY = "crema.assistant.uiControl"
type Status =
| { kind: "probing" }
| { kind: "live"; models: string[] }
| { kind: "mock"; reason: string }
const mockAdapter = new MockLLM({
label: "Mock",
delayMs: 18,
fallback:
"I'm a stand-in for the local model. Start LM Studio at localhost:1234 and reload to swap me out.",
responses: [
{
matches: (req) =>
/hello|hi\b|hey/i.test(req.messages.at(-1)?.content ?? ""),
response:
"Hi — I'm the mock assistant. Try asking me anything; I'll stream a generic reply.",
},
{
matches: (req) =>
/(take me to|open|navigate|go to).*(resources|library|settings|activity|assistant|overview|home)/i.test(
req.messages.at(-1)?.content ?? "",
),
response: [
"On it.\n\n",
"```action\n",
"navigate /resources\n",
"```\n",
],
},
],
})
export default function AssistantRoute() {
const settings = useLLMSettings()
const [status, setStatus] = useState<Status>({ kind: "probing" })
const [model, setModel] = useState<string>(() => {
if (typeof window === "undefined") return "mock"
return localStorage.getItem(STORAGE_KEY) ?? ""
})
const probe = useCallback(() => {
const ac = new AbortController()
setStatus({ kind: "probing" })
withTimeout(
listModels({ baseURL: settings.baseURL, signal: ac.signal }),
PROBE_TIMEOUT_MS,
ac.signal,
)
.then((rows) => {
const ids = rows.map((m) => m.id)
if (ids.length === 0) {
setStatus({ kind: "mock", reason: "LM Studio returned no models" })
return
}
setStatus({ kind: "live", models: ids })
setModel((cur) => (cur && ids.includes(cur) ? cur : ids[0]))
})
.catch((err: unknown) => {
if ((err as DOMException)?.name === "AbortError") return
setStatus({
kind: "mock",
reason: err instanceof Error ? err.message : "LM Studio unreachable",
})
})
return () => ac.abort()
}, [settings.baseURL])
useEffect(() => probe(), [probe])
useEffect(() => {
if (model && model !== "mock") localStorage.setItem(STORAGE_KEY, model)
}, [model])
const adapter: LLMAdapter = useMemo(
() =>
status.kind === "live"
? new OpenAICompatibleAdapter({ baseURL: settings.baseURL })
: mockAdapter,
[status.kind, settings.baseURL],
)
const activeModel =
status.kind === "live" ? model || status.models[0] : "mock"
return (
<AppShell title="Assistant" theme="mightypix">
<LLMProvider adapter={adapter} model={activeModel}>
<AssistantSurface
status={status}
model={model}
onModelChange={setModel}
contextTokens={settings.contextTokens}
responseBudget={settings.responseBudget}
baseURL={settings.baseURL}
onRetryProbe={probe}
/>
</LLMProvider>
</AppShell>
)
}
function AssistantSurface({
status,
model,
onModelChange,
contextTokens,
responseBudget,
baseURL,
onRetryProbe,
}: {
status: Status
model: string
onModelChange: (m: string) => void
contextTokens: number
responseBudget: number
baseURL: string
onRetryProbe: () => void
}) {
const [uiControl, setUiControl] = useState<boolean>(() => {
if (typeof window === "undefined") return false
return localStorage.getItem(UI_CONTROL_KEY) === "1"
})
const [actionLog, setActionLog] = useState<string | null>(null)
useEffect(() => {
localStorage.setItem(UI_CONTROL_KEY, uiControl ? "1" : "0")
}, [uiControl])
const { messages, send, abort, isStreaming, error, reset } = useChat({
system:
"You are a concise assistant inside this app. Prefer short, clear answers.",
})
const scrollerRef = useRef<HTMLDivElement>(null)
const lastContent = messages.at(-1)?.content
useEffect(() => {
const el = scrollerRef.current
if (!el) return
el.scrollTop = el.scrollHeight
}, [messages.length, lastContent, isStreaming])
// Run action blocks when an assistant turn completes (and UI control is on).
const wasStreaming = useRef(false)
useEffect(() => {
if (wasStreaming.current && !isStreaming) {
const last = messages.at(-1)
if (uiControl && last?.role === "assistant" && last.content) {
void runActionBlocks(last.content).then((res) => {
if (res.ran > 0) {
setActionLog(`Ran ${res.ran} action block${res.ran > 1 ? "s" : ""}.`)
} else if (res.errors.length > 0) {
setActionLog(`Action error: ${res.errors[0]}`)
}
})
}
}
wasStreaming.current = isStreaming
}, [isStreaming, messages, uiControl])
const handleSend = (text: string) => {
const system = uiControl
? buildSystemPrompt({ path: window.location.pathname })
: "You are a concise assistant inside this app. Prefer short, clear answers."
const sysTokens = estimateTokens(system)
const historyBudget = Math.max(
256,
contextTokens - sysTokens - responseBudget,
)
const userMsg = { role: "user" as const, content: text }
const trimmed = trimMessages([...messages, userMsg], historyBudget)
void send(text, {
system,
messages: trimmed,
maxTokens: responseBudget,
})
}
const usedTokens = useMemo(() => {
const system = uiControl
? buildSystemPrompt({ path: typeof window !== "undefined" ? window.location.pathname : "" })
: ""
const sysT = estimateTokens(system)
const histT = messages.reduce((n, m) => n + estimateTokens(m.content), 0)
return sysT + histT
}, [messages, uiControl])
const suggestions = uiControl
? [
"Take me to the resources page",
"Show me what's on the screen",
"Open the settings",
"Go back to overview",
]
: [
"What can this app do?",
"Draft a release note",
"Summarize this week's activity",
"Show me a SQL example",
]
return (
<div className="flex h-[calc(100svh-3.5rem-3rem)] flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-card/50 px-3 py-2">
<StatusDot status={status} />
<span className="text-sm text-muted-foreground">
{status.kind === "probing" && "Probing…"}
{status.kind === "live" && `Live · ${baseURL.replace(/^https?:\/\//, "")}`}
{status.kind === "mock" && `Mock LLM · ${status.reason}`}
</span>
{status.kind === "mock" && (
<Button
data-action="assistant-retry-probe"
variant="ghost"
size="icon-sm"
onClick={onRetryProbe}
aria-label="Retry connection"
title="Retry connection to LM Studio"
>
<RefreshCw className="size-4" />
</Button>
)}
<span
className={`rounded-md border px-2 py-1 text-xs tabular-nums ${
usedTokens > contextTokens - responseBudget
? "border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300"
: "bg-muted text-muted-foreground"
}`}
title={`System + history estimate. Response capped at ${responseBudget}.`}
>
{usedTokens} / {contextTokens}
</span>
<div className="ml-auto flex items-center gap-2">
{status.kind === "live" ? (
<select
data-action="assistant-model"
value={model || status.models[0]}
onChange={(e) => onModelChange(e.target.value)}
className="h-8 rounded-md border bg-background px-2 text-sm"
>
{status.models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : (
<span className="rounded-md border bg-muted px-2 py-1 text-xs">
mock
</span>
)}
<Button
data-action="assistant-ui-control"
variant={uiControl ? "default" : "outline"}
size="sm"
onClick={() => setUiControl((v) => !v)}
title="Let the assistant drive the UI on your behalf"
>
{uiControl ? "UI Control: ON" : "Enable UI Control"}
</Button>
{isStreaming ? (
<Button
data-action="assistant-stop"
variant="outline"
size="sm"
onClick={abort}
>
<Square className="size-3 fill-current" /> Stop
</Button>
) : (
<Button
data-action="assistant-clear"
variant="ghost"
size="sm"
onClick={reset}
disabled={messages.length === 0}
>
Clear
</Button>
)}
</div>
</div>
{uiControl && (
<div className="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-foreground/80">
UI control is on. The assistant can navigate, click, and fill on your
behalf. Watch the cursor.
</div>
)}
<div
ref={scrollerRef}
className="flex-1 overflow-y-auto rounded-lg border bg-card/30 p-4"
>
{messages.length === 0 ? (
<EmptyState uiControl={uiControl} />
) : (
<div className="flex flex-col gap-3">
{messages.map((m, i) =>
m.role === "user" ? (
<ChatBubble
key={i}
content={m.content}
sender="user"
name="You"
/>
) : (
<div key={i} className="flex flex-row gap-2">
<div className="flex max-w-[80%] flex-col items-start">
<span className="mb-0.5 px-1 text-xs font-medium text-muted-foreground">
Assistant
</span>
<div className="rounded-2xl rounded-bl-md bg-muted px-3.5 py-2 text-sm leading-relaxed text-foreground">
{m.content ? (
<MessageBody content={m.content} />
) : isStreaming && i === messages.length - 1 ? (
"…"
) : null}
</div>
</div>
</div>
),
)}
{isStreaming && messages.at(-1)?.role !== "assistant" && (
<TypingIndicator />
)}
</div>
)}
</div>
{actionLog && (
<div className="rounded-lg border bg-card/40 px-3 py-2 text-xs text-muted-foreground">
{actionLog}
</div>
)}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error.message}
</div>
)}
<CommandBar
chips={suggestions}
position="bottom"
placeholder={
uiControl ? "Ask me to do something…" : "Ask the assistant…"
}
onSubmit={handleSend}
onChipSelect={handleSend}
/>
</div>
)
}
function StatusDot({ status }: { status: Status }) {
const color =
status.kind === "probing"
? "bg-muted-foreground/40"
: status.kind === "live"
? "bg-green-500"
: "bg-amber-500"
return (
<span
aria-hidden
className={`size-2 rounded-full ${color}`}
data-status={status.kind}
/>
)
}
function EmptyState({ uiControl }: { uiControl: boolean }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
<p
style={{ fontFamily: "var(--font-ai-prose)" }}
className="text-lg text-foreground"
>
{uiControl ? "Tell me what you'd like to do." : "How can I help?"}
</p>
<p className="text-sm text-muted-foreground">
{uiControl
? "I can navigate, click buttons, and fill forms. Watch the cursor."
: "Pick a suggestion below or type a question."}
</p>
</div>
)
}

96
app/routes/home.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { ArrowRight, Sparkles, Boxes, Activity, BookOpen } from "lucide-react"
import { Link } from "react-router"
import { AppShell } from "~/components/layout/app-shell"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Overview")
const tiles = [
{
to: "/assistant",
icon: Sparkles,
title: "Assistant",
body: "AI-first surface — chat, suggestions, and full UI control.",
accent: true,
},
{
to: "/resources",
icon: Boxes,
title: "Resources",
body: "Traditional list + detail surface for managed entities.",
},
{
to: "/activity",
icon: Activity,
title: "Activity",
body: "Event stream and audit log.",
},
{
to: "/library",
icon: BookOpen,
title: "Library",
body: "Saved items, templates, reusable artifacts.",
},
]
export default function HomeRoute() {
return (
<AppShell title="Overview">
<Card>
<CardHeader>
<CardTitle>Welcome</CardTitle>
<CardDescription>
A hybrid traditional + AI-first scaffold. Use the rail to navigate;
the Assistant can drive the UI on your behalf try{" "}
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
P
</kbd>{" "}
for the script runner.
</CardDescription>
</CardHeader>
</Card>
<div className="grid gap-4 md:grid-cols-2">
{tiles.map((t) => {
const Icon = t.icon
return (
<Link
key={t.to}
to={t.to}
data-action={`home-tile-${t.title.toLowerCase()}`}
className="group block"
>
<Card
className={[
"h-full transition-colors",
t.accent
? "border-primary/30 bg-primary/5 hover:border-primary/50"
: "hover:border-foreground/20",
].join(" ")}
>
<CardHeader>
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-5" />
</div>
<CardTitle className="flex items-center gap-2">
{t.title}
<ArrowRight className="size-4 opacity-0 transition-opacity group-hover:opacity-100" />
</CardTitle>
<CardDescription>{t.body}</CardDescription>
</CardHeader>
</Card>
</Link>
)
})}
</div>
</AppShell>
)
}

43
app/routes/library.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { BookOpen } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Library")
export default function LibraryRoute() {
return (
<AppShell title="Library">
<Card>
<CardHeader>
<CardTitle>Library</CardTitle>
<CardDescription>
Saved items, templates, and reusable artifacts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
<BookOpen className="size-6" />
</div>
<div className="max-w-md">
<p className="font-medium">Library is empty</p>
<p className="mt-1 text-sm text-muted-foreground">
A home for AI-generated artifacts and saved snippets pair
with <code className="font-mono text-xs">@crema/artifact-ui</code>{" "}
when you start producing them.
</p>
</div>
</div>
</CardContent>
</Card>
</AppShell>
)
}

44
app/routes/resources.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { Boxes } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Resources")
export default function ResourcesRoute() {
return (
<AppShell title="Resources">
<Card>
<CardHeader>
<CardTitle>Resources</CardTitle>
<CardDescription>
A list/detail surface for the entities your app manages.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
<Boxes className="size-6" />
</div>
<div className="max-w-md">
<p className="font-medium">No resources yet</p>
<p className="mt-1 text-sm text-muted-foreground">
This route is the canonical "traditional" surface. Drop in{" "}
<code className="font-mono text-xs">@crema/table-ui</code> or{" "}
<code className="font-mono text-xs">@crema/data-ui</code> when
you have data to show.
</p>
</div>
</div>
</CardContent>
</Card>
</AppShell>
)
}

203
app/routes/settings.tsx Normal file
View File

@@ -0,0 +1,203 @@
import { useEffect, useState } from "react"
import { Check, X, Loader2 } from "lucide-react"
import { listModels } from "@crema/llm-ui"
import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import {
DEFAULT_SETTINGS,
saveLLMSettings,
useLLMSettings,
type LLMSettings,
} from "~/lib/llm-settings"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Settings")
type TestState =
| { kind: "idle" }
| { kind: "running" }
| { kind: "ok"; count: number }
| { kind: "fail"; reason: string }
export default function SettingsRoute() {
const settings = useLLMSettings()
const [draft, setDraft] = useState<LLMSettings>(settings)
const [savedAt, setSavedAt] = useState<number | null>(null)
const [test, setTest] = useState<TestState>({ kind: "idle" })
useEffect(() => {
setDraft(settings)
}, [settings])
const runTest = async () => {
setTest({ kind: "running" })
const ac = new AbortController()
const timeout = setTimeout(() => ac.abort(), 4000)
try {
const rows = await listModels({ baseURL: draft.baseURL, signal: ac.signal })
setTest({ kind: "ok", count: rows.length })
} catch (e) {
setTest({
kind: "fail",
reason: e instanceof Error ? e.message : String(e),
})
} finally {
clearTimeout(timeout)
}
}
const dirty =
draft.baseURL !== settings.baseURL ||
draft.contextTokens !== settings.contextTokens ||
draft.responseBudget !== settings.responseBudget
const save = () => {
saveLLMSettings(draft)
setSavedAt(Date.now())
}
const reset = () => {
setDraft(DEFAULT_SETTINGS)
}
return (
<AppShell title="Settings">
<Card>
<CardHeader>
<CardTitle>LLM</CardTitle>
<CardDescription>
Configure the local model endpoint and context budgets used by the
Assistant.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<Field
label="Base URL"
hint="OpenAI-compatible endpoint. LM Studio defaults to http://localhost:1234/v1."
>
<Input
data-action="settings-base-url"
value={draft.baseURL}
onChange={(e) =>
setDraft((d) => ({ ...d, baseURL: e.target.value }))
}
placeholder="http://localhost:1234/v1"
spellCheck={false}
autoComplete="off"
/>
</Field>
<Field
label="Context window (tokens)"
hint="Match this to the context length you've loaded in LM Studio."
>
<Input
data-action="settings-context-tokens"
type="number"
min={1024}
step={512}
value={draft.contextTokens}
onChange={(e) =>
setDraft((d) => ({
...d,
contextTokens: Number(e.target.value) || d.contextTokens,
}))
}
/>
</Field>
<Field
label="Response cap (max tokens)"
hint="Upper bound on each model reply. Smaller = faster, less rambling."
>
<Input
data-action="settings-response-budget"
type="number"
min={64}
step={64}
value={draft.responseBudget}
onChange={(e) =>
setDraft((d) => ({
...d,
responseBudget: Number(e.target.value) || d.responseBudget,
}))
}
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="settings-save"
onClick={save}
disabled={!dirty}
>
Save
</Button>
<Button
data-action="settings-test"
variant="outline"
onClick={runTest}
disabled={test.kind === "running"}
>
{test.kind === "running" ? (
<Loader2 className="size-4 animate-spin" />
) : test.kind === "ok" ? (
<Check className="size-4 text-emerald-600" />
) : test.kind === "fail" ? (
<X className="size-4 text-destructive" />
) : null}
Test connection
</Button>
<Button
data-action="settings-reset"
variant="outline"
onClick={reset}
>
Reset to defaults
</Button>
{savedAt && !dirty && (
<span className="text-sm text-muted-foreground">Saved.</span>
)}
{test.kind === "ok" && (
<span className="text-sm text-emerald-700 dark:text-emerald-400">
{test.count} model{test.count === 1 ? "" : "s"} available.
</span>
)}
{test.kind === "fail" && (
<span className="text-sm text-destructive" title={test.reason}>
Failed: {test.reason.slice(0, 60)}
</span>
)}
</div>
</CardContent>
</Card>
</AppShell>
)
}
function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
{children}
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
</label>
)
}