revert(studio): pull the audition surface back to what B7 and C1 specify

Re-read `plans/audio-mixer-groups.md` and `plans/audio-execution/{B7,C1}.md`
against what this session actually shipped. Three things I added were mine, not
the plan's, and they go:

- **"Bus level" on the group strip.** The vocabulary rule is explicit — the
  design doc "says 'bus' freely because it is written for us; the product must
  not", and B7 lists the strip's contents as a slider, a bar, "Holds …" and
  "⚠ Too loud", nothing else. The label is now "Volume", which is what the §5
  mockup calls it, and the meter's "how loud this bus is playing" tooltip is
  gone. "Too loud" regains the ⚠ the mockup gives it.

- **The `silentReason` warning banner.** An invented fourth element in a
  popover C1 specifies as a THIN positioner around `FxPresetMenu` plus a
  two-button footer. Removed from all five files it had been threaded through.

- **The audition's playhead jump (`auditionStart` + `auditionSpans`).** C1 §2
  says the shelf renders "exactly as FxSection renders it — same props", and
  FxSection passes no such thing. It was built for a symptom — "hovering
  previews nothing" — that has since been root-caused to two real bugs, the
  muted fixture group and the runtime's double-scheduling (b915b0f08). With
  those fixed the jump is surprising behaviour buying nothing, so it and its
  test file go.

Kept, because they ARE the plan and were simply missing:

- the shared `useAuditionTransport` (C1's "same props" — FxSection has passed
  `onAuditionTransport` since the leveller landed; the popover passed none),
- the group-member rail and indent (B2 §4, "member rows render with the accent
  rail — a left border on the header cell", never implemented),
- `hf-audio-group` resolving to `audioFx` and not to layout/style in
  `resolveEditingSections` (C1 §2's "Open the rack" is unreachable otherwise),
- the popover's viewport clamp (C1 §2, "clamped to viewport").

Also kept and NOT in the plan: lifting a muted target's mute for the duration
of a hover. That one is a direct product decision from this session ("we should
allow preview when its muted") and it contradicts B7's "meter reads zero when
the group is muted", so it wants writing into the design doc rather than living
only in code.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD is clean, studio suite 4323 green.
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:49 -07:00
parent 92081f4818
commit e728108275
8 changed files with 14 additions and 131 deletions
@@ -18,10 +18,7 @@ import {
} from "@hyperframes/core/audio-fx";
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
import {
useAuditionTransport,
type AuditionSpan,
} from "../../components/editor/useAuditionTransport.js";
import { useAuditionTransport } from "../../components/editor/useAuditionTransport.js";
function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
if (!raw) return { version: 1, nodes: [] };
@@ -39,12 +36,6 @@ interface TimelineFxButtonChainProps {
fxChainRaw: string | undefined;
onChainChange: (next: HfAudioFxChain) => void;
onChainPreview?: (next: HfAudioFxChain) => void;
/** The clips this chain is heard through, so an audition can start where they
* actually sound instead of playing silence from a playhead parked before
* the first one. */
auditionSpans?: readonly AuditionSpan[];
/** Why an audition here will be silent (excluded by someone else's solo). */
silentReason?: string | null;
/** Whether this target is muted right now. */
isMuted?: boolean;
/** Set this target's mute on the running graph WITHOUT touching the document,
@@ -161,10 +152,9 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
if (on) borrowedMute.current = props.isMuted === true;
if (borrowedMute.current) props.onSetMutedLive?.(!on);
if (!on) borrowedMute.current = false;
transport(on, props.auditionSpans);
transport(on);
}}
onOpenRack={props.onOpenRack}
silentReason={props.silentReason}
/>,
document.body,
)}
@@ -73,10 +73,10 @@ export function TimelineGroupBusStrip({
style={{ top: TRACK_H, height: STRIP_H }}
>
{/* Named, because unnamed it reads as an unexplained slider next to an
empty capsule: the row opens off a control labelled "lanes", so the
first question it has to answer is what it IS. Two words, no dB and no
numeric readout — that part of the casual-user rule stands. */}
<span className="shrink-0 text-white/45">Bus level</span>
empty capsule. "Volume" is the design mockup's own label (groups doc
§5) — B7's list is slider, bar, "Holds …", "⚠ Too loud" and NOTHING
else, and the vocabulary rule bans "bus" from the product outright. */}
<span className="shrink-0 text-white/45">Volume</span>
<input
type="range"
aria-label="Group volume"
@@ -99,9 +99,6 @@ export function TimelineGroupBusStrip({
<div
className="relative h-1.5 w-16 shrink-0 overflow-hidden rounded-full"
style={{ background: theme.gutterBorder }}
// Empty while the transport is stopped, which is when somebody is most
// likely to be wondering what it is.
title="How loud this bus is playing right now"
aria-hidden="true"
>
<div
@@ -112,7 +109,7 @@ export function TimelineGroupBusStrip({
}}
/>
</div>
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]">Too loud</span>}
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]"> Too loud</span>}
<span className="min-w-0 flex-1 truncate" title={holdsText}>
{holdsText}
</span>
@@ -3,7 +3,6 @@ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import { TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
import { TimelineFxButton } from "./TimelineFxButton";
import type { AuditionSpan } from "../../components/editor/useAuditionTransport.js";
interface TimelineGroupHeaderProps {
label: string;
@@ -28,10 +27,6 @@ interface TimelineGroupHeaderProps {
fxChain?: string;
onFxChainChange: (next: HfAudioFxChain) => void;
onFxChainPreview?: (next: HfAudioFxChain) => void;
/** Member clips, so hovering a preset auditions where the group sounds. */
auditionSpans?: readonly AuditionSpan[];
/** Why an audition here will be silent (excluded by someone else's solo). */
silentReason?: string | null;
/** Set the group mute on the running graph only, so an audition can lift it. */
onSetMutedLive?: (muted: boolean) => void;
onOpenFxRack: () => void;
@@ -59,8 +54,6 @@ export function TimelineGroupHeader({
fxChain,
onFxChainChange,
onFxChainPreview,
auditionSpans,
silentReason,
onSetMutedLive,
onOpenFxRack,
columnWidth,
@@ -156,8 +149,6 @@ export function TimelineGroupHeader({
fxChainRaw={fxChain}
onChainChange={onFxChainChange}
onChainPreview={onFxChainPreview}
auditionSpans={auditionSpans}
silentReason={silentReason}
isMuted={hidden}
onSetMutedLive={onSetMutedLive}
onOpenRack={onOpenFxRack}
@@ -86,12 +86,6 @@ export function TimelineGroupRow({
// reading (and rendering) as muted throughout.
const setGroupMutedLive = (muted: boolean) =>
onSetAudioGroupAttributeLive?.(group.id, "data-hidden", muted ? "" : null);
// Solo is not ours to borrow the same way — it is a statement about every
// other track, and lifting it would silence the one the author soloed. Say so
// instead, or the shelf auditions into silence and reads as broken.
const silencedBySolo =
soloed.size > 0 && !soloed.has(group.id) && !memberIds.some((id) => soloed.has(id));
const silentReason = silencedBySolo ? "Another track is soloed — presets here are silent." : null;
const openGroupFxRack = () => {
const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
group.id,
@@ -139,8 +133,6 @@ export function TimelineGroupRow({
fxChain={group.fxChain}
onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
auditionSpans={memberElements}
silentReason={silentReason}
onSetMutedLive={setGroupMutedLive}
onOpenFxRack={openGroupFxRack}
// Same width as every other row's header. The group row needs a real
@@ -490,23 +490,13 @@ export function TimelineTrackHeader({
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
auditionSpans={[singleAudioClip]}
// Mute is borrowed for the hover and put back (see the group row);
// solo is not ours to lift, so it gets said out loud instead.
// Muted, an audition is silent — so the hover lifts the mute on
// the running graph and puts it back on the way out, the same
// borrow-and-return it already does with the playhead.
isMuted={isTrackHidden}
onSetMutedLive={(muted) =>
onSetElementAttributeLive?.(singleAudioClip, "data-hidden", muted ? "" : null)
}
// The clip's own mute is borrowed above. A mute that lives
// somewhere else — the bus this clip hangs off, or another
// track's solo — is not this row's to lift, so it gets said.
silentReason={
singleAudioClip.audioGroupHidden
? "This clip's group is muted — unmute the group to hear presets."
: soloed.size > 0 && !(soloTargetId !== null && soloed.has(soloTargetId))
? "Another track is soloed — presets here are silent."
: null
}
onOpenRack={() => openClipFxRack(singleAudioClip)}
/>
)}