Files
inkreach-agent/docs/ui-ref/beautifului/fine-tune-card.json
T

19 lines
13 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "fine-tune-card",
"type": "registry:component",
"title": "Fine-tune Card",
"description": "The agent adjusts design properties in an inspector.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json",
"https://www.beautifului.dev/r/glide-menu.json"
],
"files": [
{
"path": "components/primitives/FineTuneCard.tsx",
"content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport GlideMenu from \"@/components/primitives/GlideMenu\";\n\n/* ─────────────────────────────────────────────────────────\n * FINE-TUNE CARD — compact interactive inspector.\n * Number fields scrub: hover the label for an ↔ cursor and\n * drag to adjust, use ↑/↓ (⇧ for ×10), or type directly.\n * ───────────────────────────────────────────────────────── */\n\nfunction ScrubField({\n label,\n value,\n onChange,\n min,\n max,\n step = 1,\n suffix = \"\",\n active,\n}: {\n label: string;\n value: number;\n onChange: (v: number) => void;\n min: number;\n max: number;\n step?: number;\n suffix?: string;\n active?: boolean;\n}) {\n const drag = useRef<{ x: number; v: number } | null>(null);\n const clamp = (v: number) => Math.min(max, Math.max(min, Math.round(v)));\n\n return (\n <label\n className=\"flex h-6.5 min-w-0 items-center gap-1 rounded-chip py-1 pr-1 pl-0.5\n transition-[background-color,box-shadow] duration-200\"\n style={{\n background: active ? \"var(--accent-tint)\" : \"var(--field)\",\n boxShadow: active ? \"0 0 0 1px var(--accent)\" : \"none\",\n }}\n >\n {/* scrub handle */}\n <span\n role=\"slider\"\n aria-label={label}\n aria-valuenow={value}\n aria-valuemin={min}\n aria-valuemax={max}\n tabIndex={0}\n onPointerDown={(e) => {\n (e.target as HTMLElement).setPointerCapture(e.pointerId);\n drag.current = { x: e.clientX, v: value };\n }}\n onPointerMove={(e) => {\n if (!drag.current) return;\n onChange(clamp(drag.current.v + ((e.clientX - drag.current.x) / 2) * step));\n }}\n onPointerUp={() => (drag.current = null)}\n onKeyDown={(e) => {\n const mult = e.shiftKey ? 10 : 1;\n if (e.key === \"ArrowUp\" || e.key === \"ArrowRight\") {\n e.preventDefault();\n onChange(clamp(value + step * mult));\n } else if (e.key === \"ArrowDown\" || e.key === \"ArrowLeft\") {\n e.preventDefault();\n onChange(clamp(value - step * mult));\n }\n }}\n className=\"flex h-full shrink-0 cursor-ew-resize touch-none items-center rounded-[4px]\n px-0.5 text-[12px] text-ink-3 select-none hover:text-ink-2 focus-visible:text-accent-ink\n focus-visible:outline-none\"\n >\n {label}\n </span>\n <input\n inputMode=\"numeric\"\n value={value}\n onChange={(e) => {\n const n = Number(e.target.value.replace(/[^\\d-]/g, \"\"));\n if (!Number.isNaN(n)) onChange(clamp(n));\n }}\n aria-label={`${label} value`}\n className=\"min-w-0 flex-1 bg-transparent text-[12px] text-ink tabular-nums outline-none\"\n />\n {suffix && <span className=\"shrink-0 pr-0.5 text-[11.5px] text-ink-3\">{suffix}</span>}\n </label>\n );\n}\n\nconst SEGMENTS = [\"row\", \"col\", \"grid\"] as const;\n\nfunction SegmentIcon({ kind }: { kind: string }) {\n const dot = \"size-1.5 rounded-[2px] border-[1.2px] border-current\";\n if (kind === \"row\")\n return <span className=\"flex gap-0.5\">{[0, 1, 2].map((i) => <span key={i} className={dot} />)}</span>;\n if (kind === \"col\")\n return <span className=\"flex flex-col gap-0.5\">{[0, 1].map((i) => <span key={i} className={dot} />)}</span>;\n return (\n <span className=\"grid grid-cols-2 gap-0.5\">\n {[0, 1, 2, 3].map((i) => <span key={i} className={dot} />)}\n </span>\n );\n}\n\n/* A single scrub-able number property. `value` is the initial/default value. */\nexport type FineTuneField = {\n key: string;\n label: string;\n value: number;\n min: number;\n max: number;\n step?: number;\n suffix?: string;\n};\n/* Prominent copy strings on the card. */\nexport type FineTuneCardLabels = {\n title: string;\n layout: string;\n type: string;\n placeholder: string;\n adjust: string;\n edited: string;\n};\n/* The editable state emitted by `onChange`. */\nexport type FineTuneState = {\n segment: number;\n values: Record<string, number>;\n type: string;\n};\n\nconst FIELDS: FineTuneField[] = [\n { key: \"width\", label: \"W\", value: 324, min: 40, max: 999 },\n { key: \"height\", label: \"H\", value: 96, min: 24, max: 999 },\n { key: \"radius\", label: \"Radius\", value: 28, min: 0, max: 64 },\n { key: \"opacity\", label: \"Opacity\", value: 100, min: 0, max: 100, suffix: \"%\" },\n];\n\nconst OPTIONS = [\"Seasonal\", \"Classic\", \"Limited\"];\n\nconst DEFAULT_LABELS: FineTuneCardLabels = {\n title: \"Flavor card\",\n layout: \"Layout\",\n type: \"Type\",\n placeholder: \"Select type\",\n adjust: \"Adjust\",\n edited: \"Edited\",\n};\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n const rows: T[][] = [];\n for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));\n return rows;\n}\n\nexport type FineTuneCardProps = {\n /** Accepted for gallery/registry parity; not used by this card. */\n variant?: string;\n /** The scrub-able properties shown in the layout grid (rendered in pairs). */\n fields?: FineTuneField[];\n /** Options offered in the Type menu. */\n options?: string[];\n /** Prominent copy strings. */\n labels?: Partial<FineTuneCardLabels>;\n /** Called with the full editable state whenever the user edits it. */\n onChange?: (state: FineTuneState) => void;\n};\n\nexport default function FineTuneCard({\n fields = FIELDS,\n options = OPTIONS,\n labels,\n onChange,\n}: FineTuneCardProps) {\n const text = { ...DEFAULT_LABELS, ...labels };\n const [seg, setSeg] = useState(0);\n const [values, setValues] = useState<Record<string, number>>(() =>\n Object.fromEntries(fields.map((f) => [f.key, f.value])),\n );\n const [menuOpen, setMenuOpen] = useState(false);\n const [typeValue, setTypeValue] = useState(text.placeholder);\n\n const selectSeg = (i: number) => {\n setSeg(i);\n onChange?.({ segment: i, values, type: typeValue });\n };\n const setValue = (key: string, v: number) => {\n setValues((current) => {\n const next = { ...current, [key]: v };\n onChange?.({ segment: seg, values: next, type: typeValue });\n return next;\n });\n };\n const selectType = (value: string) => {\n setTypeValue(value);\n setMenuOpen(false);\n onChange?.({ segment: seg, values, type: value });\n };\n\n const changed = fields.some((f) => values[f.key] !== f.value);\n const done = seg !== 0 || changed || typeValue !== text.placeholder;\n\n return (\n <div className=\"relative w-full max-w-60 rounded-card bg-surface shadow-raised\">\n {/* header */}\n <div className=\"primitive-card-bar flex items-center justify-between border-b border-line\">\n <span className=\"text-[13px] font-medium text-ink\">{text.title}</span>\n {done ? (\n <span\n className=\"flex items-center gap-1.5 text-[12px] font-medium text-green\"\n style={{ animation: \"pop-in 250ms cubic-bezier(0.23,1,0.32,1) both\" }}\n >\n <svg width=\"10\" height=\"10\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"3\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n <path d=\"M20 6L9 17l-5-5\" />\n </svg>\n {text.edited}\n </span>\n ) : (\n <span className=\"flex items-center gap-1.5\">\n <span className=\"flex size-4.5 items-center justify-center rounded-[5px] border border-accent/30 bg-accent-tint\">\n <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"var(--accent)\">\n <path d=\"M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z\" />\n </svg>\n </span>\n <span\n className=\"bg-clip-text text-[12px] font-medium text-transparent\"\n style={{\n backgroundImage:\n \"linear-gradient(90deg, var(--accent) 35%, var(--accent-ink) 50%, var(--accent) 65%)\",\n backgroundSize: \"200% 100%\",\n animation: \"shimmer-text 1.4s linear infinite\",\n }}\n >\n {text.adjust}\n </span>\n </span>\n )}\n </div>\n\n {/* layout section */}\n <div className=\"primitive-card-pad flex flex-col gap-2 border-b border-line\">\n <p className=\"text-[12.5px] font-medium text-ink\">{text.layout}</p>\n {/* segmented control: gray track, raised white thumb */}\n <div className=\"relative grid grid-cols-3 rounded-control bg-field p-0.5\">\n <span\n aria-hidden\n className=\"absolute inset-y-0.5 rounded-[6px] bg-surface shadow-btn transition-transform duration-300\"\n style={{\n width: \"calc((100% - 4px) / 3)\",\n left: 2,\n transform: `translateX(${seg * 100}%)`,\n transitionTimingFunction: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n }}\n />\n {SEGMENTS.map((s, i) => (\n <button\n key={s}\n type=\"button\"\n aria-label={`${s} layout`}\n aria-pressed={i === seg}\n onClick={() => selectSeg(i)}\n className={`relative z-10 flex h-6 items-center justify-center transition-colors duration-200\n ${i === seg ? \"text-accent\" : \"text-ink-3\"}`}\n >\n <SegmentIcon kind={s} />\n </button>\n ))}\n </div>\n {chunk(fields, 2).map((pair, ri) => (\n <div key={ri} className=\"grid min-w-0 grid-cols-2 gap-2\">\n {pair.map((f) => (\n <ScrubField\n key={f.key}\n label={f.label}\n value={values[f.key]}\n onChange={(v) => setValue(f.key, v)}\n min={f.min}\n max={f.max}\n step={f.step}\n suffix={f.suffix}\n active={values[f.key] !== f.value}\n />\n ))}\n </div>\n ))}\n </div>\n\n {/* interaction section */}\n <div className=\"primitive-card-footer flex items-center justify-between\">\n <span className=\"text-[12px] text-ink-3\">{text.type}</span>\n <div className=\"relative -mr-0.5 w-30\">\n <button\n type=\"button\"\n aria-expanded={menuOpen}\n onClick={() => setMenuOpen((current) => !current)}\n className=\"flex h-6.5 w-full items-center justify-between rounded-chip bg-inset py-1 pr-1 pl-2\n shadow-hairline transition-shadow duration-200 focus-visible:outline-none\"\n style={{ boxShadow: menuOpen ? \"0 0 0 1px var(--accent)\" : undefined }}\n >\n <span className={`text-[12px] ${typeValue !== text.placeholder ? \"text-ink\" : \"text-ink-3\"}`}>\n {typeValue}\n </span>\n <svg\n width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"var(--ink-3)\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"\n className=\"transition-transform duration-200\"\n style={{ transform: menuOpen ? \"rotate(180deg)\" : \"rotate(0)\" }}\n >\n <path d=\"M6 9l6 6 6-6\" />\n </svg>\n </button>\n\n {menuOpen && (\n <div\n className=\"absolute right-0 bottom-8 z-10 w-30 rounded-[10px] bg-surface p-1 shadow-raised\"\n style={{\n animation: \"pop-in 200ms cubic-bezier(0.23,1,0.32,1) both\",\n transformOrigin: \"bottom right\",\n }}\n >\n <GlideMenu className=\"flex flex-col gap-px\" highlightClassName=\"inset-x-0 rounded-[6px] bg-field\">\n {options.map((item) => (\n <button\n key={item}\n data-menu-row\n type=\"button\"\n onClick={() => selectType(item)}\n className={`relative z-10 flex h-6.5 w-full items-center rounded-[6px] px-2 text-left text-[12.5px] text-ink ${\n item === typeValue ? \"bg-field group-hover/glide-menu:bg-transparent\" : \"\"\n }`}\n >\n {item}\n </button>\n ))}\n </GlideMenu>\n </div>\n )}\n </div>\n </div>\n </div>\n );\n}\n",
"type": "registry:component",
"target": "components/primitives/FineTuneCard.tsx"
}
]
}