Fine-tune Card
Controls & CanvasThe agent adjusts design properties in an inspector.
Interactive Live Preview
components/ui/fine-tune-card.tsxTypeScript · React 19
1"use client";23import { useRef, useState } from "react";4import GlideMenu from "@/components/primitives/GlideMenu";56/* ─────────────────────────────────────────────────────────7 * FINE-TUNE CARD — compact interactive inspector.8 * Number fields scrub: hover the label for an ↔ cursor and9 * drag to adjust, use ↑/↓ (⇧ for ×10), or type directly.10 * ───────────────────────────────────────────────────────── */1112function ScrubField({13 label,14 value,15 onChange,16 min,17 max,18 step = 1,19 suffix = "",20 active,21}: {22 label: string;23 value: number;24 onChange: (v: number) => void;25 min: number;26 max: number;27 step?: number;28 suffix?: string;29 active?: boolean;30}) {31 const drag = useRef<{ x: number; v: number } | null>(null);32 const clamp = (v: number) => Math.min(max, Math.max(min, Math.round(v)));3334 return (35 <label36 className="flex h-6.5 min-w-0 items-center gap-1 rounded-chip py-1 pr-1 pl-0.537 transition-[background-color,box-shadow] duration-200"38 style={{39 background: active ? "var(--accent-tint)" : "var(--field)",40 boxShadow: active ? "0 0 0 1px var(--accent)" : "none",41 }}42 >43 {/* scrub handle */}44 <span45 role="slider"46 aria-label={label}47 aria-valuenow={value}48 aria-valuemin={min}49 aria-valuemax={max}50 tabIndex={0}51 onPointerDown={(e) => {52 (e.target as HTMLElement).setPointerCapture(e.pointerId);53 drag.current = { x: e.clientX, v: value };54 }}55 onPointerMove={(e) => {56 if (!drag.current) return;57 onChange(clamp(drag.current.v + ((e.clientX - drag.current.x) / 2) * step));58 }}59 onPointerUp={() => (drag.current = null)}60 onKeyDown={(e) => {61 const mult = e.shiftKey ? 10 : 1;62 if (e.key === "ArrowUp" || e.key === "ArrowRight") {63 e.preventDefault();64 onChange(clamp(value + step * mult));65 } else if (e.key === "ArrowDown" || e.key === "ArrowLeft") {66 e.preventDefault();67 onChange(clamp(value - step * mult));68 }69 }}70 className="flex h-full shrink-0 cursor-ew-resize touch-none items-center rounded-[4px]71 px-0.5 text-[12px] text-ink-3 select-none hover:text-ink-2 focus-visible:text-accent-ink72 focus-visible:outline-none"73 >74 {label}75 </span>76 <input77 inputMode="numeric"78 value={value}79 onChange={(e) => {80 const n = Number(e.target.value.replace(/[^\d-]/g, ""));81 if (!Number.isNaN(n)) onChange(clamp(n));82 }}83 aria-label={`${label} value`}84 className="min-w-0 flex-1 bg-transparent text-[12px] text-ink tabular-nums outline-none"85 />86 {suffix && <span className="shrink-0 pr-0.5 text-[11.5px] text-ink-3">{suffix}</span>}87 </label>88 );89}9091const SEGMENTS = ["row", "col", "grid"] as const;9293function SegmentIcon({ kind }: { kind: string }) {94 const dot = "size-1.5 rounded-[2px] border-[1.2px] border-current";95 if (kind === "row")96 return <span className="flex gap-0.5">{[0, 1, 2].map((i) => <span key={i} className={dot} />)}</span>;97 if (kind === "col")98 return <span className="flex flex-col gap-0.5">{[0, 1].map((i) => <span key={i} className={dot} />)}</span>;99 return (100 <span className="grid grid-cols-2 gap-0.5">101 {[0, 1, 2, 3].map((i) => <span key={i} className={dot} />)}102 </span>103 );104}105106export default function FineTuneCard() {107 const [seg, setSeg] = useState(0);108 const [width, setWidth] = useState(324);109 const [height, setHeight] = useState(96);110 const [radius, setRadius] = useState(28);111 const [opacity, setOpacity] = useState(100);112 const [menuOpen, setMenuOpen] = useState(false);113 const [typeValue, setTypeValue] = useState("Select type");114 const done =115 seg !== 0 || width !== 324 || height !== 96 || radius !== 28 || opacity !== 100 || typeValue !== "Select type";116117 return (118 <div className="relative w-full max-w-60 rounded-card bg-surface shadow-raised">119 {/* header */}120 <div className="primitive-card-bar flex items-center justify-between border-b border-line">121 <span className="text-[13px] font-medium text-ink">Flavor card</span>122 {done ? (123 <span124 className="flex items-center gap-1.5 text-[12px] font-medium text-green"125 style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}126 >127 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">128 <path d="M20 6L9 17l-5-5" />129 </svg>130 Edited131 </span>132 ) : (133 <span className="flex items-center gap-1.5">134 <span className="flex size-4.5 items-center justify-center rounded-[5px] border border-accent/30 bg-accent-tint">135 <svg width="9" height="9" viewBox="0 0 24 24" fill="var(--accent)">136 <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />137 </svg>138 </span>139 <span140 className="bg-clip-text text-[12px] font-medium text-transparent"141 style={{142 backgroundImage:143 "linear-gradient(90deg, var(--accent) 35%, var(--accent-ink) 50%, var(--accent) 65%)",144 backgroundSize: "200% 100%",145 animation: "shimmer-text 1.4s linear infinite",146 }}147 >148 Adjust149 </span>150 </span>151 )}152 </div>153154 {/* layout section */}155 <div className="primitive-card-pad flex flex-col gap-2 border-b border-line">156 <p className="text-[12.5px] font-medium text-ink">Layout</p>157 {/* segmented control: gray track, raised white thumb */}158 <div className="relative grid grid-cols-3 rounded-control bg-field p-0.5">159 <span160 aria-hidden161 className="absolute inset-y-0.5 rounded-[6px] bg-surface shadow-btn transition-transform duration-300"162 style={{163 width: "calc((100% - 4px) / 3)",164 left: 2,165 transform: `translateX(${seg * 100}%)`,166 transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",167 }}168 />169 {SEGMENTS.map((s, i) => (170 <button171 key={s}172 type="button"173 aria-label={`${s} layout`}174 aria-pressed={i === seg}175 onClick={() => setSeg(i)}176 className={`relative z-10 flex h-6 items-center justify-center transition-colors duration-200177 ${i === seg ? "text-accent" : "text-ink-3"}`}178 >179 <SegmentIcon kind={s} />180 </button>181 ))}182 </div>183 <div className="grid min-w-0 grid-cols-2 gap-2">184 <ScrubField label="W" value={width} onChange={setWidth} min={40} max={999} active={width !== 324} />185 <ScrubField label="H" value={height} onChange={setHeight} min={24} max={999} active={height !== 96} />186 </div>187 <div className="grid min-w-0 grid-cols-2 gap-2">188 <ScrubField label="Radius" value={radius} onChange={setRadius} min={0} max={64} active={radius !== 28} />189 <ScrubField label="Opacity" value={opacity} onChange={setOpacity} min={0} max={100} suffix="%" active={opacity !== 100} />190 </div>191 </div>192193 {/* interaction section */}194 <div className="primitive-card-footer flex items-center justify-between">195 <span className="text-[12px] text-ink-3">Type</span>196 <div className="relative -mr-0.5 w-30">197 <button198 type="button"199 aria-expanded={menuOpen}200 onClick={() => setMenuOpen((current) => !current)}201 className="flex h-6.5 w-full items-center justify-between rounded-chip bg-inset py-1 pr-1 pl-2202 shadow-hairline transition-shadow duration-200 focus-visible:outline-none"203 style={{ boxShadow: menuOpen ? "0 0 0 1px var(--accent)" : undefined }}204 >205 <span className={`text-[12px] ${typeValue !== "Select type" ? "text-ink" : "text-ink-3"}`}>206 {typeValue}207 </span>208 <svg209 width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"210 className="transition-transform duration-200"211 style={{ transform: menuOpen ? "rotate(180deg)" : "rotate(0)" }}212 >213 <path d="M6 9l6 6 6-6" />214 </svg>215 </button>216217 {menuOpen && (218 <div219 className="absolute right-0 bottom-8 z-10 w-30 rounded-[10px] bg-surface p-1 shadow-raised"220 style={{221 animation: "pop-in 200ms cubic-bezier(0.23,1,0.32,1) both",222 transformOrigin: "bottom right",223 }}224 >225 <GlideMenu className="flex flex-col gap-px" highlightClassName="inset-x-0 rounded-[6px] bg-field">226 {["Seasonal", "Classic", "Limited"].map((item) => (227 <button228 key={item}229 data-menu-row230 type="button"231 onClick={() => {232 setTypeValue(item);233 setMenuOpen(false);234 }}235 className={`relative z-10 flex h-6.5 w-full items-center rounded-[6px] px-2 text-left text-[12.5px] text-ink ${236 item === typeValue ? "bg-field group-hover/glide-menu:bg-transparent" : ""237 }`}238 >239 {item}240 </button>241 ))}242 </GlideMenu>243 </div>244 )}245 </div>246 </div>247 </div>248 );249}250
Props & Specification
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | "default" | Visual state and layout presentation mode. |
| className | string | "" | Optional Tailwind CSS wrapper class overrides. |