From 5a224743575b89177da9e9b564dac68b2cf4a7ec Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 12:58:54 -0700 Subject: [PATCH] refactor: derive the dataset keys for the FX and automation attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/audioAutomation.test.ts | 10 ++++++++++ packages/core/src/audioAutomation.ts | 3 +++ packages/core/src/audioFx.test.ts | 11 +++++++++++ packages/core/src/audioFx.ts | 10 ++++++++++ .../studio/src/components/editor/audioFxSummary.ts | 4 ++-- .../components/editor/propertyPanelAudioFxGroup.tsx | 9 +++++++-- .../src/components/editor/propertyPanelAutomation.ts | 3 ++- .../src/components/editor/useVolumeAutomation.ts | 7 +++++-- 8 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/core/src/audioAutomation.test.ts b/packages/core/src/audioAutomation.test.ts index 7b9af2c747..61fefbf313 100644 --- a/packages/core/src/audioAutomation.test.ts +++ b/packages/core/src/audioAutomation.test.ts @@ -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" }); diff --git a/packages/core/src/audioAutomation.ts b/packages/core/src/audioAutomation.ts index 5278cd7d1c..fe6909b719 100644 --- a/packages/core/src/audioAutomation.ts +++ b/packages/core/src/audioAutomation.ts @@ -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; diff --git a/packages/core/src/audioFx.test.ts b/packages/core/src/audioFx.test.ts index 3caaa2464c..09b60ae660 100644 --- a/packages/core/src/audioFx.test.ts +++ b/packages/core/src/audioFx.test.ts @@ -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")); diff --git a/packages/core/src/audioFx.ts b/packages/core/src/audioFx.ts index 32be62bc64..8ab6c60cc0 100644 --- a/packages/core/src/audioFx.ts +++ b/packages/core/src/audioFx.ts @@ -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; diff --git a/packages/studio/src/components/editor/audioFxSummary.ts b/packages/studio/src/components/editor/audioFxSummary.ts index 441217a8ab..a56ee5323c 100644 --- a/packages/studio/src/components/editor/audioFxSummary.ts +++ b/packages/studio/src/components/editor/audioFxSummary.ts @@ -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; diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index bcc0e16ed5..5d132620e9 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -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; }) { 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); /** diff --git a/packages/studio/src/components/editor/propertyPanelAutomation.ts b/packages/studio/src/components/editor/propertyPanelAutomation.ts index d940eedfab..3469ce29fa 100644 --- a/packages/studio/src/components/editor/propertyPanelAutomation.ts +++ b/packages/studio/src/components/editor/propertyPanelAutomation.ts @@ -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 }; diff --git a/packages/studio/src/components/editor/useVolumeAutomation.ts b/packages/studio/src/components/editor/useVolumeAutomation.ts index b0cfbb7aa1..02a2f728ab 100644 --- a/packages/studio/src/components/editor/useVolumeAutomation.ts +++ b/packages/studio/src/components/editor/useVolumeAutomation.ts @@ -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[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.