19 lines
13 KiB
JSON
19 lines
13 KiB
JSON
{
|
||
"$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"
|
||
}
|
||
]
|
||
} |