feat(web): ParamsPanel splits params into basic + advanced disclosure
Browse files- web/src/components/ParamsPanel.tsx +84 -65
- web/src/lib/api.ts +1 -0
- web/src/test/ParamsPanel.test.tsx +34 -0
web/src/components/ParamsPanel.tsx
CHANGED
|
@@ -6,78 +6,97 @@ type Props = {
|
|
| 6 |
onChange: (next: Record<string, unknown>) => void;
|
| 7 |
};
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
export default function ParamsPanel({ specs, values, onChange }: Props) {
|
| 10 |
function set(name: string, v: unknown) {
|
| 11 |
onChange({ ...values, [name]: v });
|
| 12 |
}
|
|
|
|
|
|
|
| 13 |
return (
|
| 14 |
<div className="space-y-5">
|
| 15 |
-
{
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
<span className="font-mono text-[12px] text-foreground tracking-wider">
|
| 25 |
-
{Number.isFinite(n) ? n.toFixed(2) : String(current)}
|
| 26 |
-
</span>
|
| 27 |
-
</div>
|
| 28 |
-
<input
|
| 29 |
-
id={id}
|
| 30 |
-
aria-label={s.label}
|
| 31 |
-
type="range"
|
| 32 |
-
min={s.min}
|
| 33 |
-
max={s.max}
|
| 34 |
-
step={s.step ?? 0.01}
|
| 35 |
-
value={Number.isFinite(n) ? n : 0}
|
| 36 |
-
onChange={(e) => set(s.name, Number(e.target.value))}
|
| 37 |
-
className="w-full accent-[hsl(var(--ember))]"
|
| 38 |
-
/>
|
| 39 |
-
{s.help && (
|
| 40 |
-
<p className="text-[11px] text-muted-foreground/80 italic">{s.help}</p>
|
| 41 |
-
)}
|
| 42 |
-
</div>
|
| 43 |
-
);
|
| 44 |
-
}
|
| 45 |
-
if (s.type === "bool") {
|
| 46 |
-
return (
|
| 47 |
-
<label
|
| 48 |
-
key={s.name}
|
| 49 |
-
htmlFor={id}
|
| 50 |
-
className="flex items-center justify-between cursor-pointer"
|
| 51 |
-
>
|
| 52 |
-
<span className="label-mono">{s.label}</span>
|
| 53 |
-
<input
|
| 54 |
-
id={id}
|
| 55 |
-
aria-label={s.label}
|
| 56 |
-
type="checkbox"
|
| 57 |
-
checked={!!current}
|
| 58 |
-
onChange={(e) => set(s.name, e.target.checked)}
|
| 59 |
-
className="accent-[hsl(var(--ember))]"
|
| 60 |
-
/>
|
| 61 |
-
</label>
|
| 62 |
-
);
|
| 63 |
-
}
|
| 64 |
-
return (
|
| 65 |
-
<div key={s.name} className="space-y-1.5">
|
| 66 |
-
<label htmlFor={id} className="label-mono block">{s.label}</label>
|
| 67 |
-
<select
|
| 68 |
-
id={id}
|
| 69 |
-
aria-label={s.label}
|
| 70 |
-
value={String(current)}
|
| 71 |
-
onChange={(e) => set(s.name, e.target.value)}
|
| 72 |
-
className="field-input font-mono text-[12px]"
|
| 73 |
-
>
|
| 74 |
-
{(s.choices ?? []).map((c) => (
|
| 75 |
-
<option key={c} value={c}>{c}</option>
|
| 76 |
-
))}
|
| 77 |
-
</select>
|
| 78 |
</div>
|
| 79 |
-
|
| 80 |
-
|
| 81 |
</div>
|
| 82 |
);
|
| 83 |
}
|
|
|
|
| 6 |
onChange: (next: Record<string, unknown>) => void;
|
| 7 |
};
|
| 8 |
|
| 9 |
+
function renderControl(
|
| 10 |
+
s: ParamSpec,
|
| 11 |
+
values: Record<string, unknown>,
|
| 12 |
+
set: (name: string, v: unknown) => void,
|
| 13 |
+
) {
|
| 14 |
+
const id = `param-${s.name}`;
|
| 15 |
+
const current: unknown = values[s.name] ?? s.default;
|
| 16 |
+
if (s.type === "float" || s.type === "int") {
|
| 17 |
+
const n = typeof current === "number" ? current : Number(current);
|
| 18 |
+
return (
|
| 19 |
+
<div key={s.name} className="space-y-1.5">
|
| 20 |
+
<div className="flex items-baseline justify-between">
|
| 21 |
+
<label htmlFor={id} className="label-mono">{s.label}</label>
|
| 22 |
+
<span className="font-mono text-[12px] text-foreground tracking-wider">
|
| 23 |
+
{Number.isFinite(n) ? n.toFixed(2) : String(current)}
|
| 24 |
+
</span>
|
| 25 |
+
</div>
|
| 26 |
+
<input
|
| 27 |
+
id={id}
|
| 28 |
+
aria-label={s.label}
|
| 29 |
+
type="range"
|
| 30 |
+
min={s.min}
|
| 31 |
+
max={s.max}
|
| 32 |
+
step={s.step ?? 0.01}
|
| 33 |
+
value={Number.isFinite(n) ? n : 0}
|
| 34 |
+
onChange={(e) => set(s.name, Number(e.target.value))}
|
| 35 |
+
className="w-full accent-[hsl(var(--ember))]"
|
| 36 |
+
/>
|
| 37 |
+
{s.help && (
|
| 38 |
+
<p className="text-[11px] text-muted-foreground/80 italic">{s.help}</p>
|
| 39 |
+
)}
|
| 40 |
+
</div>
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
if (s.type === "bool") {
|
| 44 |
+
return (
|
| 45 |
+
<label
|
| 46 |
+
key={s.name}
|
| 47 |
+
htmlFor={id}
|
| 48 |
+
className="flex items-center justify-between cursor-pointer"
|
| 49 |
+
>
|
| 50 |
+
<span className="label-mono">{s.label}</span>
|
| 51 |
+
<input
|
| 52 |
+
id={id}
|
| 53 |
+
aria-label={s.label}
|
| 54 |
+
type="checkbox"
|
| 55 |
+
checked={!!current}
|
| 56 |
+
onChange={(e) => set(s.name, e.target.checked)}
|
| 57 |
+
className="accent-[hsl(var(--ember))]"
|
| 58 |
+
/>
|
| 59 |
+
</label>
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
return (
|
| 63 |
+
<div key={s.name} className="space-y-1.5">
|
| 64 |
+
<label htmlFor={id} className="label-mono block">{s.label}</label>
|
| 65 |
+
<select
|
| 66 |
+
id={id}
|
| 67 |
+
aria-label={s.label}
|
| 68 |
+
value={String(current)}
|
| 69 |
+
onChange={(e) => set(s.name, e.target.value)}
|
| 70 |
+
className="field-input font-mono text-[12px]"
|
| 71 |
+
>
|
| 72 |
+
{(s.choices ?? []).map((c) => (
|
| 73 |
+
<option key={c} value={c}>{c}</option>
|
| 74 |
+
))}
|
| 75 |
+
</select>
|
| 76 |
+
</div>
|
| 77 |
+
);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
export default function ParamsPanel({ specs, values, onChange }: Props) {
|
| 81 |
function set(name: string, v: unknown) {
|
| 82 |
onChange({ ...values, [name]: v });
|
| 83 |
}
|
| 84 |
+
const basic = specs.filter((s) => (s.group ?? "basic") === "basic");
|
| 85 |
+
const advanced = specs.filter((s) => s.group === "advanced");
|
| 86 |
return (
|
| 87 |
<div className="space-y-5">
|
| 88 |
+
{basic.map((s) => renderControl(s, values, set))}
|
| 89 |
+
{advanced.length > 0 && (
|
| 90 |
+
<details className="card-paper p-3 [&_summary::-webkit-details-marker]:hidden">
|
| 91 |
+
<summary className="label-mono cursor-pointer select-none flex items-center gap-2">
|
| 92 |
+
<span className="inline-block transition-transform [details[open]>summary>&]:rotate-90">▸</span>
|
| 93 |
+
advanced · {advanced.length} params
|
| 94 |
+
</summary>
|
| 95 |
+
<div className="mt-4 space-y-5">
|
| 96 |
+
{advanced.map((s) => renderControl(s, values, set))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
</div>
|
| 98 |
+
</details>
|
| 99 |
+
)}
|
| 100 |
</div>
|
| 101 |
);
|
| 102 |
}
|
web/src/lib/api.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type ParamSpec = {
|
|
| 10 |
step?: number;
|
| 11 |
choices?: string[];
|
| 12 |
help?: string;
|
|
|
|
| 13 |
};
|
| 14 |
|
| 15 |
export type ModelInfo = {
|
|
|
|
| 10 |
step?: number;
|
| 11 |
choices?: string[];
|
| 12 |
help?: string;
|
| 13 |
+
group?: "basic" | "advanced";
|
| 14 |
};
|
| 15 |
|
| 16 |
export type ModelInfo = {
|
web/src/test/ParamsPanel.test.tsx
CHANGED
|
@@ -24,3 +24,37 @@ describe("ParamsPanel", () => {
|
|
| 24 |
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ exaggeration: 1.2 }));
|
| 25 |
});
|
| 26 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ exaggeration: 1.2 }));
|
| 25 |
});
|
| 26 |
});
|
| 27 |
+
|
| 28 |
+
const specsMixed: ParamSpec[] = [
|
| 29 |
+
{ name: "temperature", label: "Temperature", type: "float", default: 0.8, min: 0.1, max: 1.5, step: 0.05, group: "basic" },
|
| 30 |
+
{ name: "seed", label: "Seed", type: "int", default: -1, min: -1, step: 1, group: "advanced" },
|
| 31 |
+
{ name: "top_p", label: "Top p", type: "float", default: 1.0, min: 0, max: 1, step: 0.01, group: "advanced" },
|
| 32 |
+
];
|
| 33 |
+
|
| 34 |
+
describe("ParamsPanel groups", () => {
|
| 35 |
+
it("renders basic params and a closed advanced disclosure by default", () => {
|
| 36 |
+
render(<ParamsPanel specs={specsMixed} values={{}} onChange={() => {}} />);
|
| 37 |
+
expect(screen.getByLabelText(/temperature/i)).toBeInTheDocument();
|
| 38 |
+
// advanced is in the DOM but not visible until <details> opens
|
| 39 |
+
const seed = screen.getByLabelText(/^seed$/i) as HTMLInputElement;
|
| 40 |
+
const detailsAncestor = seed.closest("details");
|
| 41 |
+
expect(detailsAncestor).not.toBeNull();
|
| 42 |
+
expect(detailsAncestor!.open).toBe(false);
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it("opens disclosure on summary click and shows advanced params", () => {
|
| 46 |
+
render(<ParamsPanel specs={specsMixed} values={{}} onChange={() => {}} />);
|
| 47 |
+
const summary = screen.getByText(/advanced/i);
|
| 48 |
+
fireEvent.click(summary);
|
| 49 |
+
const seed = screen.getByLabelText(/^seed$/i) as HTMLInputElement;
|
| 50 |
+
expect(seed.closest("details")!.open).toBe(true);
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it("propagates onChange from advanced params", () => {
|
| 54 |
+
const onChange = vi.fn();
|
| 55 |
+
render(<ParamsPanel specs={specsMixed} values={{}} onChange={onChange} />);
|
| 56 |
+
fireEvent.click(screen.getByText(/advanced/i));
|
| 57 |
+
fireEvent.change(screen.getByLabelText(/^top p$/i), { target: { value: "0.6" } });
|
| 58 |
+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ top_p: 0.6 }));
|
| 59 |
+
});
|
| 60 |
+
});
|