@hyperframes/studio 0.7.107 → 0.7.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-QqavTWRK.js} +1 -1
- package/dist/assets/{index-BA9yhzfR.js → index-BQjXA1pz.js} +1 -1
- package/dist/assets/{index-BSBk5srs.js → index-BhHHMprP.js} +1 -1
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/assets/index-GqONLcOl.js +428 -0
- package/dist/index.d.ts +85 -2
- package/dist/index.html +2 -2
- package/dist/index.js +17696 -12512
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/contexts/DomEditContext.tsx +11 -0
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +30 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +38 -0
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +2 -0
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/system.ts +8 -0
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioTelemetry.ts +16 -10
- package/dist/assets/index-dF-CmLZu.js +0 -428
- package/dist/assets/index-zQ4JFwwB.css +0 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One preset's bracket in the rack: its own header, on/off switch, amount lane
|
|
3
|
+
* and the rows it wraps — or, for a run with no preset, just the rows.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `propertyPanelFxSection.tsx`, whose `runs.map()` callback this
|
|
6
|
+
* body used to be — one card per run, hand-built nodes included as runs with no
|
|
7
|
+
* preset attached.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
HfAudioFxNode,
|
|
12
|
+
HfAudioFxParam,
|
|
13
|
+
HfAudioFxParamValues,
|
|
14
|
+
} from "@hyperframes/core/audio-fx";
|
|
15
|
+
import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
|
|
16
|
+
import { FxParamRow } from "./propertyPanelFxControls.js";
|
|
17
|
+
import { fxPresetBackground, fxPresetStyle } from "./propertyPanelFxPresetStyle.js";
|
|
18
|
+
import { FxNodeRow } from "./propertyPanelFxNodeRow.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The one control over a whole preset: how much of it is applied.
|
|
22
|
+
*
|
|
23
|
+
* Not in the effect registry — a preset is not an effect — so the row is
|
|
24
|
+
* fabricated the same way the derived one-knob control is, and rendered by the
|
|
25
|
+
* ordinary controls.
|
|
26
|
+
*/
|
|
27
|
+
const PRESET_AMOUNT_PARAM: HfAudioFxParam = {
|
|
28
|
+
kind: "number",
|
|
29
|
+
key: "amount",
|
|
30
|
+
label: "Amount",
|
|
31
|
+
unit: "",
|
|
32
|
+
min: 0,
|
|
33
|
+
max: 1,
|
|
34
|
+
step: 0.01,
|
|
35
|
+
default: 1,
|
|
36
|
+
hint: "How much of this preset is applied. Automate it to bring the whole preset in or out over time.",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface FxPresetRunProps {
|
|
40
|
+
run: { preset?: string; items: { node: HfAudioFxNode; i: number }[] };
|
|
41
|
+
/** The number each row wears, counted over the whole rack. */
|
|
42
|
+
positions: ReadonlyMap<number, number>;
|
|
43
|
+
/** So the last row in the WHOLE chain knows it cannot move further down. */
|
|
44
|
+
totalNodes: number;
|
|
45
|
+
automatedTargets?: ReadonlySet<string>;
|
|
46
|
+
liveAutomationValues?: ReadonlyMap<string, number>;
|
|
47
|
+
onAutomateParam?(nodeId: string, paramKey: string): void;
|
|
48
|
+
onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
|
|
49
|
+
openNode: number | null;
|
|
50
|
+
onToggleOpenNode(index: number): void;
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
onUpdateNode(index: number, patch: Partial<HfAudioFxNode>): void;
|
|
53
|
+
onMoveNode(index: number, delta: number): void;
|
|
54
|
+
onRemoveNode(index: number): void;
|
|
55
|
+
onPreviewNode(index: number, params: HfAudioFxParamValues): void;
|
|
56
|
+
/** What the track reads as, carried onto each row's own telemetry events. */
|
|
57
|
+
trackKind?: string;
|
|
58
|
+
/** Whether this run's card is folded shut. Meaningless when there is no preset. */
|
|
59
|
+
collapsed: boolean;
|
|
60
|
+
onToggleCollapse(): void;
|
|
61
|
+
/** How much of the preset is applied, 0..1 — the switch and the lane read the same value. */
|
|
62
|
+
amount: number;
|
|
63
|
+
onSetAmount(amount: number, persist?: boolean): void;
|
|
64
|
+
onRemoveRun(): void;
|
|
65
|
+
automated: boolean;
|
|
66
|
+
onAutomate?(): void;
|
|
67
|
+
onRemoveAutomation?(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One run: a preset's bracket around its nodes, or a bare hand-built node. */
|
|
71
|
+
export function FxPresetRun({
|
|
72
|
+
run,
|
|
73
|
+
positions,
|
|
74
|
+
totalNodes,
|
|
75
|
+
automatedTargets,
|
|
76
|
+
liveAutomationValues,
|
|
77
|
+
onAutomateParam,
|
|
78
|
+
onRemoveParamAutomation,
|
|
79
|
+
openNode,
|
|
80
|
+
onToggleOpenNode,
|
|
81
|
+
disabled,
|
|
82
|
+
onUpdateNode,
|
|
83
|
+
onMoveNode,
|
|
84
|
+
onRemoveNode,
|
|
85
|
+
onPreviewNode,
|
|
86
|
+
trackKind,
|
|
87
|
+
collapsed,
|
|
88
|
+
onToggleCollapse,
|
|
89
|
+
amount,
|
|
90
|
+
onSetAmount,
|
|
91
|
+
onRemoveRun,
|
|
92
|
+
automated,
|
|
93
|
+
onAutomate,
|
|
94
|
+
onRemoveAutomation,
|
|
95
|
+
}: FxPresetRunProps) {
|
|
96
|
+
const rows = run.items.map(({ node, i }) => (
|
|
97
|
+
<FxNodeRow
|
|
98
|
+
// Keyed by id, as the carve module's list above already is. On
|
|
99
|
+
// `${type}-${index}` two effects of the same type keep their keys through
|
|
100
|
+
// a reorder, so React reuses each row where it stands — and the controls
|
|
101
|
+
// hold real state (a half-typed number, an in-flight drag), which then
|
|
102
|
+
// lands on whichever effect moved into that slot.
|
|
103
|
+
key={node.id ?? `${node.type}-${i}`}
|
|
104
|
+
node={node}
|
|
105
|
+
index={i}
|
|
106
|
+
position={positions.get(i)}
|
|
107
|
+
automatedTargets={automatedTargets}
|
|
108
|
+
liveAutomationValues={liveAutomationValues}
|
|
109
|
+
onAutomateParam={onAutomateParam}
|
|
110
|
+
onRemoveParamAutomation={onRemoveParamAutomation}
|
|
111
|
+
open={openNode === i}
|
|
112
|
+
last={i === totalNodes - 1}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
onToggleOpen={() => onToggleOpenNode(i)}
|
|
115
|
+
onUpdate={onUpdateNode}
|
|
116
|
+
onMove={onMoveNode}
|
|
117
|
+
onRemove={onRemoveNode}
|
|
118
|
+
onPreview={onPreviewNode}
|
|
119
|
+
trackKind={trackKind}
|
|
120
|
+
/>
|
|
121
|
+
));
|
|
122
|
+
|
|
123
|
+
const preset = run.preset ? getAudioFxPreset(run.preset) : null;
|
|
124
|
+
if (!preset) return rows;
|
|
125
|
+
|
|
126
|
+
// On unless every node in it is bypassed: one switched back on means the
|
|
127
|
+
// preset is doing something, and the switch has to offer to stop it rather
|
|
128
|
+
// than claiming it has already stopped.
|
|
129
|
+
const runOn = amount > 0;
|
|
130
|
+
const style = fxPresetStyle(run.preset ?? "");
|
|
131
|
+
const background = fxPresetBackground(run.preset ?? "");
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
className="hf-fx-preset-run space-y-1 rounded-[4px] border border-l-2 border-dashed border-panel-border-input p-1"
|
|
136
|
+
data-fx-preset={run.preset}
|
|
137
|
+
data-collapsed={collapsed ? "" : undefined}
|
|
138
|
+
// The bracket's edge carries the preset's own colour, the way a module's
|
|
139
|
+
// carries its family's — and the wash behind it is the same hue taken to
|
|
140
|
+
// near-black, so a rack with three presets in it reads as three regions
|
|
141
|
+
// rather than one long list.
|
|
142
|
+
style={{
|
|
143
|
+
borderLeftColor: style.color,
|
|
144
|
+
...(background ? { backgroundColor: background } : {}),
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<div className="hf-fx-preset-run-head flex min-h-6 items-center gap-1 px-0.5">
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
className={`hf-fx-preset-run-label min-w-0 flex-1 truncate text-left leading-tight hover:opacity-80 ${style.type}`}
|
|
151
|
+
// The face and the colour are data, not classes: a Tailwind class
|
|
152
|
+
// cannot name a font stack the config does not know, and adding eight
|
|
153
|
+
// to the config to style one panel would put them in every
|
|
154
|
+
// autocomplete in the studio.
|
|
155
|
+
style={{
|
|
156
|
+
color: style.color,
|
|
157
|
+
...(style.family ? { fontFamily: style.family } : {}),
|
|
158
|
+
}}
|
|
159
|
+
aria-expanded={!collapsed}
|
|
160
|
+
title={
|
|
161
|
+
collapsed ? `Show what ${preset.label} contains` : `Hide ${preset.label}'s effects`
|
|
162
|
+
}
|
|
163
|
+
onClick={onToggleCollapse}
|
|
164
|
+
>
|
|
165
|
+
<span className="hf-fx-preset-run-caret pr-1 font-mono opacity-60" aria-hidden="true">
|
|
166
|
+
{collapsed ? "▸" : "▾"}
|
|
167
|
+
</span>
|
|
168
|
+
{preset.label}
|
|
169
|
+
{/* Collapsed, the count is what says the preset is still a chain
|
|
170
|
+
rather than one opaque effect. */}
|
|
171
|
+
{collapsed ? (
|
|
172
|
+
<span className="hf-fx-preset-run-count pl-1.5 font-mono text-[9px] opacity-60">
|
|
173
|
+
{run.items.length}
|
|
174
|
+
</span>
|
|
175
|
+
) : null}
|
|
176
|
+
</button>
|
|
177
|
+
{/* The whole preset, on or off. Partly-bypassed reads as off, because
|
|
178
|
+
"some of it is running" is not a state an author set — it is one
|
|
179
|
+
they arrived at, and the switch is how they get back out of it. */}
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
className="hf-fx-preset-run-toggle rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
|
|
183
|
+
aria-pressed={runOn}
|
|
184
|
+
title={runOn ? `Switch ${preset.label} off` : `Switch ${preset.label} back on`}
|
|
185
|
+
disabled={disabled}
|
|
186
|
+
onClick={() => onSetAmount(runOn ? 0 : 1)}
|
|
187
|
+
>
|
|
188
|
+
{runOn ? "On" : "Off"}
|
|
189
|
+
</button>
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
className="hf-fx-preset-run-remove px-1 font-mono text-[11px] text-panel-text-2 hover:text-red-400 disabled:opacity-40"
|
|
193
|
+
title={`Remove ${preset.label}`}
|
|
194
|
+
disabled={disabled}
|
|
195
|
+
onClick={onRemoveRun}
|
|
196
|
+
>
|
|
197
|
+
×
|
|
198
|
+
</button>
|
|
199
|
+
</div>
|
|
200
|
+
{/* The same value the switch sets, so an author can put the preset half
|
|
201
|
+
in — and the lane below ramps it continuously. */}
|
|
202
|
+
<FxParamRow
|
|
203
|
+
param={PRESET_AMOUNT_PARAM}
|
|
204
|
+
value={amount}
|
|
205
|
+
disabled={disabled || automated}
|
|
206
|
+
automated={automated}
|
|
207
|
+
onChange={(_k, v) => onSetAmount(Number(v), false)}
|
|
208
|
+
onCommit={(_k, v) => onSetAmount(Number(v))}
|
|
209
|
+
onAutomate={onAutomate}
|
|
210
|
+
onRemoveAutomation={onRemoveAutomation}
|
|
211
|
+
/>
|
|
212
|
+
{collapsed ? null : rows}
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { HF_AUDIO_FX_PRESETS } from "@hyperframes/core/audio-fx-presets";
|
|
4
|
+
import {
|
|
5
|
+
FX_PRESET_STYLE,
|
|
6
|
+
FX_PRESET_STYLE_DEFAULT,
|
|
7
|
+
fxPresetBackground,
|
|
8
|
+
fxPresetStyle,
|
|
9
|
+
fxTintWash,
|
|
10
|
+
} from "./propertyPanelFxPresetStyle.js";
|
|
11
|
+
|
|
12
|
+
describe("per-preset title treatments", () => {
|
|
13
|
+
it("styles every preset the catalogue ships", () => {
|
|
14
|
+
// An unstyled preset is not broken — it falls back — but it is a row that
|
|
15
|
+
// silently opts out of the design, which nobody would notice.
|
|
16
|
+
const missing = HF_AUDIO_FX_PRESETS.filter((p) => !FX_PRESET_STYLE[p.id]).map((p) => p.id);
|
|
17
|
+
expect(missing).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("styles no preset the catalogue does not", () => {
|
|
21
|
+
// Dead entries for renamed or removed presets read as coverage.
|
|
22
|
+
const shipped = new Set(HF_AUDIO_FX_PRESETS.map((p) => p.id));
|
|
23
|
+
expect(Object.keys(FX_PRESET_STYLE).filter((id) => !shipped.has(id))).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("gives the character presets treatments that differ from each other", () => {
|
|
27
|
+
// The whole point: a preset is a character, and Telephone should not look
|
|
28
|
+
// like Megaphone. The corrective families may legitimately share a look.
|
|
29
|
+
const character = HF_AUDIO_FX_PRESETS.filter((p) => p.family === "character");
|
|
30
|
+
const looks = new Set(
|
|
31
|
+
character.map((p) => `${fxPresetStyle(p.id).type}|${fxPresetStyle(p.id).family}`),
|
|
32
|
+
);
|
|
33
|
+
expect(looks.size).toBe(character.length);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("names a real font stack, ending in a generic the browser always has", () => {
|
|
37
|
+
// The studio has no webfont pipeline, so these are system faces — and a
|
|
38
|
+
// machine without the named one has to land somewhere deliberate rather
|
|
39
|
+
// than on the browser's default serif.
|
|
40
|
+
const generic = /(sans-serif|serif|monospace|fantasy|cursive|ui-monospace)\s*$/;
|
|
41
|
+
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
|
|
42
|
+
expect(style.family, `${id} names no face`).toBeTruthy();
|
|
43
|
+
expect(style.family, `${id} has no generic fallback`).toMatch(generic);
|
|
44
|
+
// More than one option, or it is a single point of failure.
|
|
45
|
+
expect((style.family ?? "").split(",").length, `${id} has no fallback chain`).toBeGreaterThan(
|
|
46
|
+
2,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("sets a title size on every preset, and keeps it legible", () => {
|
|
52
|
+
// The panel's own rows are 10px; a title at that size is not a title. The
|
|
53
|
+
// ceiling is what still fits the bracket without wrapping.
|
|
54
|
+
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
|
|
55
|
+
const size = /text-\[(\d+)px\]/.exec(style.type);
|
|
56
|
+
expect(size, `${id} sets no title size`).toBeTruthy();
|
|
57
|
+
const px = Number(size?.[1]);
|
|
58
|
+
expect(px, `${id} is too small to read as a title`).toBeGreaterThanOrEqual(12);
|
|
59
|
+
expect(px, `${id} is too large for the bracket`).toBeLessThanOrEqual(18);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("keeps every colour vibrant, and light enough to read on the panel", () => {
|
|
64
|
+
// The rack sits on #0C0C0E. A title has to carry real colour to be worth
|
|
65
|
+
// having, and still clear contrast against near-black.
|
|
66
|
+
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
|
|
67
|
+
const match = /hsl\(\s*(\d+),\s*(\d+)%,\s*(\d+)%\s*\)/.exec(style.color);
|
|
68
|
+
expect(match, `${id} is not a plain hsl() colour`).toBeTruthy();
|
|
69
|
+
const saturation = Number(match?.[1 + 1]);
|
|
70
|
+
const lightness = Number(match?.[3]);
|
|
71
|
+
expect(saturation, `${id} is too washed out to read as a colour`).toBeGreaterThanOrEqual(60);
|
|
72
|
+
expect(lightness, `${id} is too dark against the panel`).toBeGreaterThanOrEqual(58);
|
|
73
|
+
expect(lightness, `${id} is so light the hue disappears`).toBeLessThanOrEqual(78);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("keeps the title hues clear of the accent, which means something else", () => {
|
|
78
|
+
// The panel spends #3CE6AC (hue 160) on "automated" and "playing". A title
|
|
79
|
+
// sitting on that hue reads as a status the preset does not have.
|
|
80
|
+
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
|
|
81
|
+
const hue = Number(/hsl\(\s*(\d+),/.exec(style.color)?.[1]);
|
|
82
|
+
const distance = Math.min(Math.abs(hue - 160), 360 - Math.abs(hue - 160));
|
|
83
|
+
expect(distance, `${id} sits on the accent's hue`).toBeGreaterThan(20);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("backs each preset with its own hue, dark enough to sit under the panel", () => {
|
|
88
|
+
// Derived from the title rather than picked, so a background cannot drift
|
|
89
|
+
// away from the title it belongs to.
|
|
90
|
+
const seen = new Set<string>();
|
|
91
|
+
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
|
|
92
|
+
const bg = fxPresetBackground(id);
|
|
93
|
+
expect(bg, `${id} has no background`).toBeTruthy();
|
|
94
|
+
const titleHue = /hsl\(\s*(\d+),/.exec(style.color)?.[1];
|
|
95
|
+
expect(bg, `${id}'s background is a different hue from its title`).toContain(
|
|
96
|
+
`hsl(${titleHue},`,
|
|
97
|
+
);
|
|
98
|
+
const lightness = Number(/,\s*(\d+)%\s*\)/.exec(bg ?? "")?.[1]);
|
|
99
|
+
expect(lightness, `${id}'s background would fight the controls on it`).toBeLessThanOrEqual(
|
|
100
|
+
16,
|
|
101
|
+
);
|
|
102
|
+
seen.add(bg ?? "");
|
|
103
|
+
}
|
|
104
|
+
// Presets that share a title colour share a background — the repair family
|
|
105
|
+
// is deliberately uniform — but the character ones must not.
|
|
106
|
+
const character = HF_AUDIO_FX_PRESETS.filter((p) => p.family === "character");
|
|
107
|
+
expect(new Set(character.map((p) => fxPresetBackground(p.id))).size).toBe(character.length);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("has no background for a preset it does not know", () => {
|
|
111
|
+
expect(fxPresetBackground("not-a-preset")).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("falls back rather than failing for a preset it does not know", () => {
|
|
115
|
+
expect(fxPresetStyle("not-a-preset")).toBe(FX_PRESET_STYLE_DEFAULT);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The wash is shared with the smart modules, which do not have preset entries
|
|
121
|
+
* to look up — the carve derives its colour from `fxFamilyTint`, and that one
|
|
122
|
+
* COMPUTES its lightness, so it emits `62.0%` where every preset colour is a
|
|
123
|
+
* whole number. An integer-only pattern reads those as no match and returns
|
|
124
|
+
* null, which renders as no wash at all rather than as an error.
|
|
125
|
+
*/
|
|
126
|
+
describe("the tint wash", () => {
|
|
127
|
+
it("reads a computed colour, decimals and all", () => {
|
|
128
|
+
expect(fxTintWash("hsl(95, 32%, 62.0%)")).toBe("hsl(95, 22%, 11%)");
|
|
129
|
+
expect(fxTintWash("hsl(95, 32.5%, 76%)")).toBe("hsl(95, 22%, 11%)");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("keeps the hue and takes everything else to near-black", () => {
|
|
133
|
+
// Same hue in, same hue out: the wash cannot clash with the title it sits
|
|
134
|
+
// behind, because it IS the title's hue.
|
|
135
|
+
expect(fxTintWash("hsl(288, 85%, 72%)")).toBe("hsl(288, 22%, 11%)");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("declines a colour it cannot read rather than guessing", () => {
|
|
139
|
+
expect(fxTintWash("#52525B")).toBeNull();
|
|
140
|
+
expect(fxTintWash("rebeccapurple")).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A title treatment per preset — the label as a small piece of design rather
|
|
3
|
+
* than eighteen rows of the same condensed caps.
|
|
4
|
+
*
|
|
5
|
+
* The rack already letters by FAMILY (`propertyPanelFxFamily.ts`), which answers
|
|
6
|
+
* "what kind of thing is this". This answers a different question: a preset is a
|
|
7
|
+
* character, and the point of Telephone or Megaphone is that you know what it
|
|
8
|
+
* sounds like before you play it. Type can carry that — a bullhorn's name should
|
|
9
|
+
* look shouted, a tape's should look worn.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately NOT a per-preset colour free-for-all. Each hue sits in the same
|
|
12
|
+
* narrow lightness band as the family tints so nothing reads as a status colour,
|
|
13
|
+
* and the panel already spends saturation on "automated" and "bypassed".
|
|
14
|
+
*
|
|
15
|
+
* A preset with no entry falls back to the neutral treatment, so the catalogue
|
|
16
|
+
* can grow without this file — an unstyled preset looks plain, not broken.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface FxPresetStyle {
|
|
20
|
+
/** Tailwind classes for the title: weight, case, tracking, size. */
|
|
21
|
+
type: string;
|
|
22
|
+
/** The title's colour, and the bracket's left edge. */
|
|
23
|
+
color: string;
|
|
24
|
+
/**
|
|
25
|
+
* A real font stack, when the character wants one.
|
|
26
|
+
*
|
|
27
|
+
* Tailwind ships three generic families, and a set of presets styled only
|
|
28
|
+
* with those ends up variations of the same two faces. These are system
|
|
29
|
+
* faces with a documented fallback chain: the studio has no webfont
|
|
30
|
+
* pipeline, and the Google Fonts cache under `~/.cache/hyperframes` belongs
|
|
31
|
+
* to the CLI's composition build — reaching into it from the panel would be
|
|
32
|
+
* inventing a second one.
|
|
33
|
+
*
|
|
34
|
+
* Every stack ends in a generic keyword, so a machine without the named face
|
|
35
|
+
* still lands somewhere deliberate.
|
|
36
|
+
*/
|
|
37
|
+
family?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Faces that ship with macOS and/or Windows, grouped by what they read as. */
|
|
41
|
+
const FACE = {
|
|
42
|
+
/** Narrow industrial caps — signage, stencils, equipment panels. */
|
|
43
|
+
condensed: '"Haettenschweiler", "Arial Narrow", Impact, sans-serif',
|
|
44
|
+
/** Geometric and mechanical; reads as a machine rather than a voice. */
|
|
45
|
+
geometric: '"Futura", "Century Gothic", "Avenir Next", sans-serif',
|
|
46
|
+
/** Typewriter — struck, worn, slightly irregular. */
|
|
47
|
+
typewriter: '"American Typewriter", "Courier New", Courier, monospace',
|
|
48
|
+
/** High-contrast editorial serif; broadcast and print authority. */
|
|
49
|
+
editorial: '"Didot", "Bodoni 72", "Playfair Display", Georgia, serif',
|
|
50
|
+
/** Old-style serif with real warmth — books, not headlines. */
|
|
51
|
+
bookish: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
|
52
|
+
/** Display face with no restraint at all. Theatrical, and a bit absurd. */
|
|
53
|
+
theatrical: '"Luminari", "Papyrus", "Comic Sans MS", fantasy',
|
|
54
|
+
/** Engraved caps — plaques, institutions, things bolted to walls. */
|
|
55
|
+
engraved: '"Copperplate", "Optima", "Perpetua Titling MT", serif',
|
|
56
|
+
/** Terminal type: fixed, plain, no personality of its own. */
|
|
57
|
+
terminal: '"SF Mono", Consolas, Menlo, ui-monospace, monospace',
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
/** Plain, and what any preset without its own entry gets. */
|
|
61
|
+
export const FX_PRESET_STYLE_DEFAULT: FxPresetStyle = {
|
|
62
|
+
type: "text-[12px] uppercase tracking-wide",
|
|
63
|
+
color: "hsl(220, 24%, 72%)",
|
|
64
|
+
family: FACE.terminal,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const FX_PRESET_STYLE: Record<string, FxPresetStyle> = {
|
|
68
|
+
// --- voice: reach for these to sound like yourself, only better -----------
|
|
69
|
+
// Restrained rather than plain. These are corrective, and a costume would
|
|
70
|
+
// promise a character they deliberately do not add — but they still get a
|
|
71
|
+
// face, because "no styling at all" is what every other row in the panel has.
|
|
72
|
+
"voice-clean": {
|
|
73
|
+
type: "text-[13px] font-medium tracking-tight",
|
|
74
|
+
color: "hsl(202, 82%, 66%)",
|
|
75
|
+
family: FACE.geometric,
|
|
76
|
+
},
|
|
77
|
+
"voice-broadcast": {
|
|
78
|
+
type: "text-[13px] font-bold uppercase tracking-[0.14em]",
|
|
79
|
+
color: "hsl(214, 84%, 70%)",
|
|
80
|
+
family: FACE.editorial,
|
|
81
|
+
},
|
|
82
|
+
"voice-warm": {
|
|
83
|
+
type: "text-[14px] italic tracking-normal",
|
|
84
|
+
color: "hsl(32, 88%, 66%)",
|
|
85
|
+
family: FACE.bookish,
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// --- repair: workshop labels. Fixed, plain, nothing decorative ------------
|
|
89
|
+
"rumble-cut": {
|
|
90
|
+
type: "text-[12px] uppercase tracking-[0.18em]",
|
|
91
|
+
color: "hsl(196, 78%, 64%)",
|
|
92
|
+
family: FACE.terminal,
|
|
93
|
+
},
|
|
94
|
+
"room-gate": {
|
|
95
|
+
type: "text-[12px] uppercase tracking-[0.18em]",
|
|
96
|
+
color: "hsl(196, 78%, 64%)",
|
|
97
|
+
family: FACE.terminal,
|
|
98
|
+
},
|
|
99
|
+
"boom-tame": {
|
|
100
|
+
type: "text-[12px] uppercase tracking-[0.18em]",
|
|
101
|
+
color: "hsl(196, 78%, 64%)",
|
|
102
|
+
family: FACE.terminal,
|
|
103
|
+
},
|
|
104
|
+
"harsh-tame": {
|
|
105
|
+
type: "text-[12px] uppercase tracking-[0.18em]",
|
|
106
|
+
color: "hsl(196, 78%, 64%)",
|
|
107
|
+
family: FACE.terminal,
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// --- character: the costumes. This is where type does the work ------------
|
|
111
|
+
// A phone's band is narrow; so is the tracking, on a face with no warmth.
|
|
112
|
+
telephone: {
|
|
113
|
+
type: "text-[13px] uppercase tracking-[0.3em]",
|
|
114
|
+
color: "hsl(186, 85%, 62%)",
|
|
115
|
+
family: FACE.terminal,
|
|
116
|
+
},
|
|
117
|
+
// A dial face: high-contrast serif caps, spaced like printed frequencies.
|
|
118
|
+
"radio-am": {
|
|
119
|
+
type: "text-[14px] uppercase tracking-[0.24em]",
|
|
120
|
+
color: "hsl(42, 92%, 62%)",
|
|
121
|
+
family: FACE.editorial,
|
|
122
|
+
},
|
|
123
|
+
// Shouted through a horn — the heaviest, narrowest thing available, leaning.
|
|
124
|
+
megaphone: {
|
|
125
|
+
type: "text-[17px] font-black italic uppercase tracking-tight",
|
|
126
|
+
color: "hsl(12, 90%, 64%)",
|
|
127
|
+
family: FACE.condensed,
|
|
128
|
+
},
|
|
129
|
+
// Struck on a machine, played back years later.
|
|
130
|
+
"lofi-tape": {
|
|
131
|
+
type: "text-[13px] tracking-wide",
|
|
132
|
+
color: "hsl(28, 72%, 62%)",
|
|
133
|
+
family: FACE.typewriter,
|
|
134
|
+
},
|
|
135
|
+
// Bolted to a wall in a station concourse.
|
|
136
|
+
"pa-system": {
|
|
137
|
+
type: "text-[13px] uppercase tracking-[0.26em]",
|
|
138
|
+
color: "hsl(222, 80%, 70%)",
|
|
139
|
+
family: FACE.engraved,
|
|
140
|
+
},
|
|
141
|
+
// Small, squeezed through a grille, no room for anything but the letters.
|
|
142
|
+
intercom: {
|
|
143
|
+
type: "text-[12px] font-bold uppercase tracking-tighter",
|
|
144
|
+
color: "hsl(96, 68%, 60%)",
|
|
145
|
+
family: FACE.terminal,
|
|
146
|
+
},
|
|
147
|
+
// The name is a joke and the type is in on it.
|
|
148
|
+
"doofus-worble": {
|
|
149
|
+
type: "text-[16px] tracking-[0.1em]",
|
|
150
|
+
color: "hsl(288, 85%, 72%)",
|
|
151
|
+
family: FACE.theatrical,
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
// --- space: rooms. Light and wide, because that is what space looks like ---
|
|
155
|
+
"room-tight": {
|
|
156
|
+
type: "text-[13px] uppercase tracking-[0.2em]",
|
|
157
|
+
color: "hsl(252, 74%, 72%)",
|
|
158
|
+
family: FACE.geometric,
|
|
159
|
+
},
|
|
160
|
+
"room-natural": {
|
|
161
|
+
type: "text-[13px] uppercase tracking-[0.26em]",
|
|
162
|
+
color: "hsl(258, 78%, 72%)",
|
|
163
|
+
family: FACE.geometric,
|
|
164
|
+
},
|
|
165
|
+
// The biggest room gets the widest setting — the word itself opens out.
|
|
166
|
+
hall: {
|
|
167
|
+
type: "text-[15px] uppercase tracking-[0.4em]",
|
|
168
|
+
color: "hsl(246, 84%, 74%)",
|
|
169
|
+
family: FACE.engraved,
|
|
170
|
+
},
|
|
171
|
+
"slap-echo": {
|
|
172
|
+
type: "text-[13px] uppercase tracking-[0.28em]",
|
|
173
|
+
color: "hsl(274, 76%, 70%)",
|
|
174
|
+
family: FACE.geometric,
|
|
175
|
+
},
|
|
176
|
+
"dub-throw": {
|
|
177
|
+
type: "text-[14px] italic tracking-[0.3em]",
|
|
178
|
+
color: "hsl(312, 78%, 70%)",
|
|
179
|
+
family: FACE.editorial,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export function fxPresetStyle(presetId: string): FxPresetStyle {
|
|
184
|
+
return FX_PRESET_STYLE[presetId] ?? FX_PRESET_STYLE_DEFAULT;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The wash behind a titled module, derived from the colour of its own title.
|
|
189
|
+
*
|
|
190
|
+
* Derived rather than picked: twenty hand-chosen pairs is twenty chances for
|
|
191
|
+
* one to clash with its own title, and a hue rotation cannot. Same hue,
|
|
192
|
+
* saturation pulled right down and lightness taken to near-black, so the panel
|
|
193
|
+
* reads as tinted rather than coloured — the rack sits on `#0C0C0E` and
|
|
194
|
+
* anything with real lightness here would fight every control on top of it.
|
|
195
|
+
*
|
|
196
|
+
* Takes a colour rather than an id so the smart modules can use it too: the
|
|
197
|
+
* carve has a title worth setting and no preset entry to look up. Returns null
|
|
198
|
+
* for a colour it cannot read, which is how a caller opts out.
|
|
199
|
+
*/
|
|
200
|
+
export function fxTintWash(color: string): string | null {
|
|
201
|
+
// Decimals allowed: the preset colours are whole numbers, but `fxFamilyTint`
|
|
202
|
+
// computes its lightness and emits `62.0%`, which an integer-only pattern
|
|
203
|
+
// silently declines — the module then renders with no wash at all.
|
|
204
|
+
const num = String.raw`\d+(?:\.\d+)?`;
|
|
205
|
+
const hsl = new RegExp(`hsl\\(\\s*(${num}),\\s*${num}%,\\s*${num}%\\s*\\)`).exec(color);
|
|
206
|
+
if (!hsl) return null;
|
|
207
|
+
// 22% saturation at 11% lightness: present enough to tell two brackets apart
|
|
208
|
+
// at a glance, dark enough that white body text still clears WCAG AA on it.
|
|
209
|
+
return `hsl(${hsl[1]}, 22%, 11%)`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The wash behind a preset's bracket. Null when the preset has no character. */
|
|
213
|
+
export function fxPresetBackground(presetId: string): string | null {
|
|
214
|
+
const style = FX_PRESET_STYLE[presetId];
|
|
215
|
+
return style ? fxTintWash(style.color) : null;
|
|
216
|
+
}
|