Fine-tune Card
The agent adjusts design properties in an inspector.
"use client";
/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import "./styles/foundation.css";
import { useRef, useState } from "react";
import GlideMenu from "./GlideMenu";
/* ─────────────────────────────────────────────────────────
* FINE-TUNE CARD — compact interactive inspector.
* Number fields scrub: hover the label for an ↔ cursor and
* drag to adjust, use ↑/↓ (⇧ for ×10), or type directly.
* ───────────────────────────────────────────────────────── */
function ScrubField({
label,
value,
onChange,
min,
max,
step = 1,
suffix = "",
active,
}: {
label: string;
value: number;
onChange: (v: number) => void;
min: number;
max: number;
step?: number;
suffix?: string;
active?: boolean;
}) {
const drag = useRef<{ x: number; v: number } | null>(null);
const clamp = (v: number) => Math.min(max, Math.max(min, Math.round(v)));
return (
<label
className="flex h-6.5 min-w-0 items-center gap-1 rounded-chip py-1 pr-1 pl-0.5
transition-[background-color,box-shadow] duration-200"
style={{
background: active ? "var(--highlight-tint)" : "var(--field)",
boxShadow: active ? "0 0 0 1px var(--highlight)" : "none",
}}
>
{/* scrub handle */}
<span
role="slider"
aria-label={label}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
tabIndex={0}
onPointerDown={(e) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, v: value };
}}
onPointerMove={(e) => {
if (!drag.current) return;
onChange(clamp(drag.current.v + ((e.clientX - drag.current.x) / 2) * step));
}}
onPointerUp={() => (drag.current = null)}
onKeyDown={(e) => {
const mult = e.shiftKey ? 10 : 1;
if (e.key === "ArrowUp" || e.key === "ArrowRight") {
e.preventDefault();
onChange(clamp(value + step * mult));
} else if (e.key === "ArrowDown" || e.key === "ArrowLeft") {
e.preventDefault();
onChange(clamp(value - step * mult));
}
}}
className="flex h-full shrink-0 cursor-ew-resize touch-none items-center rounded-[4px]
px-0.5 text-[12px] text-ink-3 select-none hover:text-ink-2 focus-visible:text-highlight-ink
focus-visible:outline-none"
>
{label}
</span>
<input
inputMode="numeric"
value={value}
onChange={(e) => {
const n = Number(e.target.value.replace(/[^\d-]/g, ""));
if (!Number.isNaN(n)) onChange(clamp(n));
}}
aria-label={`${label} value`}
className="min-w-0 flex-1 bg-transparent text-[12px] text-ink tabular-nums outline-none"
/>
{suffix && <span className="shrink-0 pr-0.5 text-[11.5px] text-ink-3">{suffix}</span>}
</label>
);
}
const SEGMENTS = ["row", "col", "grid"] as const;
function SegmentIcon({ kind }: { kind: string }) {
const dot = "size-1.5 rounded-[2px] border-[1.2px] border-current";
if (kind === "row")
return <span className="flex gap-0.5">{[0, 1, 2].map((i) => <span key={i} className={dot} />)}</span>;
if (kind === "col")
return <span className="flex flex-col gap-0.5">{[0, 1].map((i) => <span key={i} className={dot} />)}</span>;
return (
<span className="grid grid-cols-2 gap-0.5">
{[0, 1, 2, 3].map((i) => <span key={i} className={dot} />)}
</span>
);
}
/* A single scrub-able number property. `value` is the initial/default value. */
export type FineTuneField = {
key: string;
label: string;
value: number;
min: number;
max: number;
step?: number;
suffix?: string;
};
/* Prominent copy strings on the card. */
export type FineTuneCardLabels = {
title: string;
layout: string;
type: string;
placeholder: string;
adjust: string;
edited: string;
};
/* The editable state emitted by `onChange`. */
export type FineTuneState = {
segment: number;
values: Record<string, number>;
type: string;
};
const FIELDS: FineTuneField[] = [
{ key: "width", label: "W", value: 324, min: 40, max: 999 },
{ key: "height", label: "H", value: 96, min: 24, max: 999 },
{ key: "radius", label: "Radius", value: 28, min: 0, max: 64 },
{ key: "opacity", label: "Opacity", value: 100, min: 0, max: 100, suffix: "%" },
];
const OPTIONS = ["Seasonal", "Classic", "Limited"];
const DEFAULT_LABELS: FineTuneCardLabels = {
title: "Project card",
layout: "Layout",
type: "Type",
placeholder: "Select type",
adjust: "Adjust",
edited: "Edited",
};
function chunk<T>(items: T[], size: number): T[][] {
const rows: T[][] = [];
for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
return rows;
}
export type FineTuneCardProps = {
/** Accepted for gallery/registry parity; not used by this card. */
variant?: string;
/** The scrub-able properties shown in the layout grid (rendered in pairs). */
fields?: FineTuneField[];
/** Options offered in the Type menu. */
options?: string[];
/** Prominent copy strings. */
labels?: Partial<FineTuneCardLabels>;
/** Called with the full editable state whenever the user edits it. */
onChange?: (state: FineTuneState) => void;
};
export default function FineTuneCard({
fields = FIELDS,
options = OPTIONS,
labels,
onChange,
}: FineTuneCardProps) {
const text = { ...DEFAULT_LABELS, ...labels };
const [seg, setSeg] = useState(0);
const [values, setValues] = useState<Record<string, number>>(() =>
Object.fromEntries(fields.map((f) => [f.key, f.value])),
);
const [menuOpen, setMenuOpen] = useState(false);
const [typeValue, setTypeValue] = useState(text.placeholder);
const selectSeg = (i: number) => {
setSeg(i);
onChange?.({ segment: i, values, type: typeValue });
};
const setValue = (key: string, v: number) => {
setValues((current) => {
const next = { ...current, [key]: v };
onChange?.({ segment: seg, values: next, type: typeValue });
return next;
});
};
const selectType = (value: string) => {
setTypeValue(value);
setMenuOpen(false);
onChange?.({ segment: seg, values, type: value });
};
const changed = fields.some((f) => values[f.key] !== f.value);
const done = seg !== 0 || changed || typeValue !== text.placeholder;
return (
<div className="relative w-full max-w-60 rounded-card bg-card shadow-raised">
{/* header */}
<div className="primitive-card-bar flex items-center justify-between border-b border-line">
<span className="text-[13px] font-medium text-ink">{text.title}</span>
{done ? (
<span
className="flex items-center gap-1.5 text-[12px] font-medium text-green"
style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
{text.edited}
</span>
) : (
<span className="flex items-center gap-1.5">
<span className="flex size-4.5 items-center justify-center rounded-[5px] border border-highlight/30 bg-highlight-tint">
<svg width="9" height="9" viewBox="0 0 24 24" fill="var(--highlight)">
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
</svg>
</span>
<span
className="bg-clip-text text-[12px] font-medium text-transparent"
style={{
backgroundImage:
"linear-gradient(90deg, var(--highlight) 35%, var(--highlight-ink) 50%, var(--highlight) 65%)",
backgroundSize: "200% 100%",
animation: "shimmer-text 1.4s linear infinite",
}}
>
{text.adjust}
</span>
</span>
)}
</div>
{/* layout section */}
<div className="primitive-card-pad flex flex-col gap-2 border-b border-line">
<p className="text-[12.5px] font-medium text-ink">{text.layout}</p>
{/* segmented control: gray track, raised white thumb */}
<div className="relative grid grid-cols-3 rounded-control bg-field p-0.5">
<span
aria-hidden
className="absolute inset-y-0.5 rounded-[6px] bg-card shadow-btn transition-transform duration-300"
style={{
width: "calc((100% - 4px) / 3)",
left: 2,
transform: `translateX(${seg * 100}%)`,
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
/>
{SEGMENTS.map((s, i) => (
<button
key={s}
type="button"
aria-label={`${s} layout`}
aria-pressed={i === seg}
onClick={() => selectSeg(i)}
className={`relative z-10 flex h-6 items-center justify-center transition-colors duration-200
${i === seg ? "text-highlight" : "text-ink-3"}`}
>
<SegmentIcon kind={s} />
</button>
))}
</div>
{chunk(fields, 2).map((pair, ri) => (
<div key={ri} className="grid min-w-0 grid-cols-2 gap-2">
{pair.map((f) => (
<ScrubField
key={f.key}
label={f.label}
value={values[f.key]}
onChange={(v) => setValue(f.key, v)}
min={f.min}
max={f.max}
step={f.step}
suffix={f.suffix}
active={values[f.key] !== f.value}
/>
))}
</div>
))}
</div>
{/* interaction section */}
<div className="primitive-card-footer flex items-center justify-between">
<span className="text-[12px] text-ink-3">{text.type}</span>
<div className="relative -mr-0.5 w-30">
<button
type="button"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((current) => !current)}
className="flex h-6.5 w-full items-center justify-between rounded-chip bg-inset py-1 pr-1 pl-2
shadow-hairline transition-shadow duration-200 focus-visible:outline-none"
style={{ boxShadow: menuOpen ? "0 0 0 1px var(--highlight)" : undefined }}
>
<span className={`text-[12px] ${typeValue !== text.placeholder ? "text-ink" : "text-ink-3"}`}>
{typeValue}
</span>
<svg
width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
className="transition-transform duration-200"
style={{ transform: menuOpen ? "rotate(180deg)" : "rotate(0)" }}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{menuOpen && (
<div
className="absolute right-0 bottom-8 z-10 w-30 rounded-[10px] bg-card p-1 shadow-raised"
style={{
animation: "pop-in 200ms cubic-bezier(0.23,1,0.32,1) both",
transformOrigin: "bottom right",
}}
>
<GlideMenu className="flex flex-col gap-px" highlightClassName="inset-x-0 rounded-[6px] bg-field">
{options.map((item) => (
<button
key={item}
data-menu-row
type="button"
onClick={() => selectType(item)}
className={`relative z-10 flex h-6.5 w-full items-center rounded-[6px] px-2 text-left text-[12.5px] text-ink ${
item === typeValue ? "bg-field group-hover/glide-menu:bg-transparent" : ""
}`}
>
{item}
</button>
))}
</GlideMenu>
</div>
)}
</div>
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add @ward/fine-tune-cardnpx shadcn@latest add @ward/fine-tune-cardyarn dlx shadcn@latest add @ward/fine-tune-cardbunx --bun shadcn@latest add @ward/fine-tune-cardAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/fine-tune-card.json
Add the Ward items it builds on.
npx shadcn@latest add @ward/foundation @ward/glide-menuCopy each file from the Code tab into:
components/ward/FineTuneCard.tsx
Update the import paths to match your project.
Usage
import FineTuneCard from "@/components/ward/FineTuneCard";<FineTuneCard
fields={[
{ key: "width", label: "W", value: 324, min: 40, max: 999 },
{ key: "height", label: "H", value: 96, min: 24, max: 999 },
]}
options={["Seasonal", "Classic", "Limited"]}
labels={{ title: "Project card" }}
onChange={(state) => console.log(state.values)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | string | "Default" | Accepted so the Ward preview can pass a variant; the card has a single look and ignores it. |
fields | FineTuneField[] | FIELDS | Number properties shown in pairs in the layout grid, which can be dragged, nudged with the arrow keys or typed; defaults to width, height, radius and opacity. |
options | string[] | ["Seasonal", "Classic", "Limited"] | Choices offered in the Type menu. |
labels | Partial<FineTuneCardLabels> | - | Copy for the title, section headings, menu placeholder and status text, merged over the built-in strings. |
onChange | (state: FineTuneState) => void | - | Called with the selected layout, field values and type whenever the user edits the card. |
Ward · Version 1.1.0 · Registry manifest