feat(studio): show the full name on hover for truncated rack labels

The rack's label column is a fixed 86px, so "Gap between repeats" reads as
"Gap between re…" and "How many repeats" as "How many repe…" — the part
that tells two knobs apart is the part that gets cut.

The row already carried a `title`, but that is the param's HINT: what the
knob does. Useful, and no substitute for the name. So the name is titled on
the label itself and the hint stays on the row — two different questions,
neither standing in for the other.

Same fix for the three other truncating labels in the rack, since a
truncated effect name is the same defect one line up: the node name, its
one-line summary, and the carve module's own name and "Listen to" row.

Titled unconditionally rather than only when the text overflows: whether
it does depends on the rendered font and the panel's width, and a title
that matches the visible text costs nothing.

Two tests, mutation-checked — the label carries the full name, the row
still carries the hint, and the label is titled even when there is no hint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:40:25 -07:00
co-authored by Claude Opus 5
parent c5c6c6313e
commit b6bac054b2
4 changed files with 70 additions and 3 deletions
@@ -230,6 +230,8 @@ export function FxCarveModule({
// sets `tracking-normal`, and two Tailwind tracking utilities on one // sets `tracking-normal`, and two Tailwind tracking utilities on one
// element resolve by stylesheet order, not by the order written. // element resolve by stylesheet order, not by the order written.
style={{ color: tint, letterSpacing: "0.16em" }} style={{ color: tint, letterSpacing: "0.16em" }}
// Truncates in a narrow panel like every other name in the rack.
title="Voiceover carve"
aria-expanded={open} aria-expanded={open}
onClick={onToggleOpen} onClick={onToggleOpen}
> >
@@ -256,7 +258,10 @@ export function FxCarveModule({
<div className="hf-fx-carve-body border-t border-panel-border-input"> <div className="hf-fx-carve-body border-t border-panel-border-input">
<div className="hf-fx-carve-controls space-y-0.5 px-1.5 py-1.5"> <div className="hf-fx-carve-controls space-y-0.5 px-1.5 py-1.5">
<div className="hf-fx-row flex min-h-6 items-center gap-2"> <div className="hf-fx-row flex min-h-6 items-center gap-2">
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-2"> <span
className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-2"
title="Listen to"
>
Listen to Listen to
</span> </span>
{soleVoice ? ( {soleVoice ? (
@@ -113,3 +113,48 @@ describe("FxParamRow commit", () => {
expect(onCommit).not.toHaveBeenCalled(); expect(onCommit).not.toHaveBeenCalled();
}); });
}); });
describe("FxParamRow label tooltip", () => {
// The label column is a fixed 86px and truncates: "Gap between repeats" reads
// as "Gap between re…", "How many repeats" as "How many repe…". The row
// already carried a `title`, but that is the HINT — what the knob does — so a
// truncated name had no way to be read in full.
const LONG: HfAudioFxNumberParam = {
kind: "number",
key: "time",
label: "Gap between repeats",
min: 0,
max: 1000,
step: 1,
default: 250,
unit: "ms",
hint: "How long before the echo comes back.",
};
it("titles the label with the full name, and leaves the hint on the row", () => {
const host = document.createElement("div");
document.body.append(host);
act(() => {
createRoot(host).render(<FxParamRow param={LONG} value={250} onChange={vi.fn()} />);
});
const label = host.querySelector<HTMLElement>(".hf-fx-label");
expect(label?.getAttribute("title")).toBe("Gap between repeats");
// The hint stays where it was: the two are different questions, and the
// name is not a substitute for the explanation either.
expect(host.querySelector<HTMLElement>(".hf-fx-row")?.getAttribute("title")).toBe(
"How long before the echo comes back.",
);
});
it("titles it even with no hint to fall back on", () => {
const host = document.createElement("div");
document.body.append(host);
const { hint: _hint, ...noHint } = LONG;
act(() => {
createRoot(host).render(<FxParamRow param={noHint} value={250} onChange={vi.fn()} />);
});
expect(host.querySelector<HTMLElement>(".hf-fx-label")?.getAttribute("title")).toBe(
"Gap between repeats",
);
});
});
@@ -188,7 +188,15 @@ export function FxParamRow({
if (param.kind === "enum") { if (param.kind === "enum") {
return ( return (
<label className="hf-fx-row flex min-h-6 items-center gap-2" title={param.hint}> <label className="hf-fx-row flex min-h-6 items-center gap-2" title={param.hint}>
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-2"> {/* `title` on the LABEL, not just the row: the row's is the hint, which
explains what the knob does — useful, and no substitute for the name
when 86px truncates it to "Gap between re…". Titled unconditionally
rather than only when it overflows, since whether it does depends on
the rendered font and the panel's width. */}
<span
className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-2"
title={param.label}
>
{param.label} {param.label}
</span> </span>
<select <select
@@ -227,10 +235,13 @@ export function FxParamRow({
title={param.hint} title={param.hint}
data-automated={automated ? "" : undefined} data-automated={automated ? "" : undefined}
> >
{/* See the enum row above for why the name is titled here as well as the
hint being titled on the row. */}
<span <span
className={`hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] ${ className={`hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] ${
automated ? "text-panel-accent" : "text-panel-text-2" automated ? "text-panel-accent" : "text-panel-text-2"
}`} }`}
title={param.label}
> >
{param.label} {param.label}
</span> </span>
@@ -184,6 +184,9 @@ function FxNodeHeader({
<button <button
type="button" type="button"
className={`hf-fx-node-name flex-1 truncate text-left text-[11px] text-panel-text-1 hover:text-panel-text-0 ${family}`} className={`hf-fx-node-name flex-1 truncate text-left text-[11px] text-panel-text-1 hover:text-panel-text-0 ${family}`}
// Truncated in the same narrow column as the param labels below, so it
// needs the same fallback to the full text on hover.
title={label}
aria-expanded={open} aria-expanded={open}
onClick={onToggleOpen} onClick={onToggleOpen}
> >
@@ -304,7 +307,10 @@ export function FxNodeRow({
onRemove={() => onRemove(index)} onRemove={() => onRemove(index)}
/> />
{summary ? ( {summary ? (
<p className="hf-fx-node-summary truncate px-1.5 pb-1 text-[10px] text-panel-text-2"> <p
className="hf-fx-node-summary truncate px-1.5 pb-1 text-[10px] text-panel-text-2"
title={summary}
>
{summary} {summary}
</p> </p>
) : null} ) : null}