refactor: derive the dataset keys for the FX and automation attributes

The studio writes these attributes through `HF_AUDIO_FX_ATTR` and
`HF_AUDIO_AUTOMATION_ATTR` but reads them out of `dataAttributes`, which is
keyed without the `data-` prefix — so the read side carried its own
`"fx-chain"` and `"automation"` literals in four places. The two spellings had
no link, so a rename could only half-land: the writes would move and the panel
would go on reading an attribute nothing writes any more.

Both keys are now derived from their attribute (`HF_AUDIO_FX_DATA_KEY`,
`HF_AUDIO_AUTOMATION_DATA_KEY`) and the four read sites use them.

Falsified two ways. Repointing the derived key breaks 24 existing panel and
summary tests, which is what proves the reads actually go through it. And the
new one-line pairing assertion in each module fails the moment the derivation
is replaced by a literal again — which is the regression it exists for, since
a hardcoded key that happens to be right today passes every other test in the
suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-08 12:58:54 -07:00
co-authored by Claude Opus 5
parent ce7c9e4867
commit 5a22474357
8 changed files with 50 additions and 7 deletions
+10
View File
@@ -3,6 +3,8 @@ import {
applyCurve,
shapeProgress,
fxAutomationTarget,
HF_AUDIO_AUTOMATION_ATTR,
HF_AUDIO_AUTOMATION_DATA_KEY,
isConstantLane,
parseAutomation,
parseAutomationTarget,
@@ -30,6 +32,14 @@ const lane = (points: HfAutomationLane["points"], target = "volume"): HfAutomati
points,
});
describe("the automation attribute's two spellings", () => {
it("names the same attribute either way", () => {
// Same split as the FX chain: written as an attribute, read as a dataset
// key. Derived, so a rename cannot half-land.
expect(HF_AUDIO_AUTOMATION_ATTR).toBe(`data-${HF_AUDIO_AUTOMATION_DATA_KEY}`);
});
});
describe("targets", () => {
it("reads volume and fx targets, and rejects anything else", () => {
expect(parseAutomationTarget("volume")).toEqual({ kind: "volume" });
+3
View File
@@ -15,6 +15,9 @@ import { getAudioFxDef, type HfAudioFxChain } from "./audioFx.js";
export const HF_AUDIO_AUTOMATION_ATTR = "data-automation";
/** The same attribute as a `dataset` / `dataAttributes` key. See `HF_AUDIO_FX_DATA_KEY`. */
export const HF_AUDIO_AUTOMATION_DATA_KEY = HF_AUDIO_AUTOMATION_ATTR.slice("data-".length);
/** Automation files are versioned; a reader must refuse a version it doesn't know. */
export const HF_AUDIO_AUTOMATION_VERSION = 1;
+11
View File
@@ -5,7 +5,9 @@ import {
enabledAudioFxNodes,
getAudioFxDef,
HF_AUDIO_FX,
HF_AUDIO_FX_ATTR,
HF_AUDIO_FX_CHAIN_VERSION,
HF_AUDIO_FX_DATA_KEY,
HF_AUDIO_FX_IDS,
normalizeAudioFxParams,
parseAudioFxChain,
@@ -47,6 +49,15 @@ describe("effect registry", () => {
});
});
describe("the chain attribute's two spellings", () => {
it("names the same attribute either way", () => {
// The studio WRITES through the attribute and READS through the dataset
// key, so the read side used to carry its own `"fx-chain"` literal and a
// rename would only half-land. Derived now; this is what keeps it derived.
expect(HF_AUDIO_FX_ATTR).toBe(`data-${HF_AUDIO_FX_DATA_KEY}`);
});
});
describe("normalizeAudioFxParams", () => {
it("fills missing keys with defaults", () => {
expect(normalizeAudioFxParams("peaking", {})).toEqual(defaultAudioFxParams("peaking"));
+10
View File
@@ -16,6 +16,16 @@
export const HF_AUDIO_FX_ATTR = "data-fx-chain";
/**
* The same attribute as a `dataset` / `dataAttributes` key — the `data-` prefix
* is not part of that spelling.
*
* Derived rather than restated: the studio writes through
* `HF_AUDIO_FX_ATTR` and reads through the key, so a hardcoded `"fx-chain"`
* on the read side is a rename waiting to half-land.
*/
export const HF_AUDIO_FX_DATA_KEY = HF_AUDIO_FX_ATTR.slice("data-".length);
/** Chain files are versioned; a reader must refuse a version it doesn't know. */
export const HF_AUDIO_FX_CHAIN_VERSION = 1;
@@ -7,11 +7,11 @@
* grouping exists to prevent — that they are seven things to manage.
*/
import { parseAudioFxChain } from "@hyperframes/core/audio-fx";
import { HF_AUDIO_FX_DATA_KEY, parseAudioFxChain } from "@hyperframes/core/audio-fx";
import type { DomEditSelection } from "./domEditingTypes";
export function audioFxSummary(element: DomEditSelection): string {
const raw = element.dataAttributes?.["fx-chain"];
const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY];
const carveAttr = element.dataAttributes?.["fx-carve"];
let handBuilt = 0;
let carveNodes = 0;
@@ -11,6 +11,7 @@ import { useEffect, useState } from "react";
import {
defaultAudioFxParams,
HF_AUDIO_FX_ATTR,
HF_AUDIO_FX_DATA_KEY,
mintAudioFxNodeId,
parseAudioFxChain,
serializeAudioFxChain,
@@ -41,6 +42,7 @@ import {
automatedTargetsOf,
automationAttrValue,
HF_AUDIO_AUTOMATION_ATTR,
HF_AUDIO_AUTOMATION_DATA_KEY,
readPanelAutomation,
resolveAutomationRange,
withoutLane,
@@ -109,7 +111,7 @@ export function AudioFxGroup({
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
}) {
const chain = ((): HfAudioFxChain => {
const raw = element.dataAttributes?.["fx-chain"];
const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY];
if (!raw) return { version: 1, nodes: [] };
try {
return parseAudioFxChain(raw);
@@ -120,7 +122,10 @@ export function AudioFxGroup({
}
})();
const automation = readPanelAutomation(element.dataAttributes?.["automation"], chain);
const automation = readPanelAutomation(
element.dataAttributes?.[HF_AUDIO_AUTOMATION_DATA_KEY],
chain,
);
const automatedTargets = automatedTargetsOf(automation);
/**
@@ -8,6 +8,7 @@
import {
HF_AUDIO_AUTOMATION_ATTR,
HF_AUDIO_AUTOMATION_DATA_KEY,
parseAutomation,
resolveAutomation,
resolveAutomationRange,
@@ -77,4 +78,4 @@ export function automationAttrValue(automation: HfAutomation): string {
return automation.lanes.length > 0 ? serializeAutomation(automation) : "";
}
export { HF_AUDIO_AUTOMATION_ATTR, resolveAutomationRange };
export { HF_AUDIO_AUTOMATION_ATTR, HF_AUDIO_AUTOMATION_DATA_KEY, resolveAutomationRange };
@@ -6,7 +6,7 @@
* rather than a second interpretation of the attribute.
*/
import { VOLUME_TARGET } from "@hyperframes/core/audio-automation";
import { HF_AUDIO_AUTOMATION_DATA_KEY, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
import type { DomEditSelection } from "./domEditingTypes";
import {
automationAttrValue,
@@ -28,7 +28,10 @@ export function useVolumeAutomation(
): VolumeAutomationBinding {
// The chain is not needed to resolve a volume lane — volume is always a valid
// target — so this deliberately does not parse it.
const automation = readPanelAutomation(element.dataAttributes?.["automation"], undefined);
const automation = readPanelAutomation(
element.dataAttributes?.[HF_AUDIO_AUTOMATION_DATA_KEY],
undefined,
);
const write = (next: Parameters<typeof automationAttrValue>[0]): void => {
// Quiet: clicking the toggle used to reload the preview and restart every
// playing track, while the same click on an effect parameter did not.