Skip to content

Commit 7222abd

Browse files
committed
feat(studio): vibrant preset colours, each with its own background
Saturation roughly doubles — the muted 26-46% band was chosen to stay out of the way, and stayed so far out of it that the titles read as grey text with a hint of tint. They are 68-92% now, and light enough (58-78%) to carry on the panel's near-black ground. Two hues moved rather than brightened. The voice presets sat at hue 155, which is the accent (#3CE6AC is hue 160) — saturating them there would have made "Clean Voice" read as automated or playing, which is what that green means everywhere else in the panel. They are blues now, and a test keeps every title at least 20° clear of the accent. **Each preset also gets its own background**, derived from its title hue rather than picked: nineteen hand-chosen pairs is nineteen chances for one to clash with its own title, and a hue rotation cannot. Same hue at 22% saturation and 11% lightness, so a rack with several presets reads as several regions instead of one long list, while staying dark enough that every control on top of it is unaffected. The repair family deliberately shares one colour and therefore one background — they are workshop tools, not characters — so the uniqueness test covers the character family only. Verified in a running studio with five presets applied: five distinct title colours over five matching washes, and the accent still reads as the only green in the panel. Falsified: a washed-out colour, a title on the accent's hue, a background bright enough to fight the controls, and a background hue unhooked from its title each fail a test. studio 3730 passing, 18 todo.
1 parent b087b80 commit 7222abd

3 files changed

Lines changed: 98 additions & 33 deletions

File tree

‎packages/studio/src/components/editor/propertyPanelFxPresetStyle.test.ts‎

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HF_AUDIO_FX_PRESETS } from "@hyperframes/core/audio-fx-presets";
44
import {
55
FX_PRESET_STYLE,
66
FX_PRESET_STYLE_DEFAULT,
7+
fxPresetBackground,
78
fxPresetStyle,
89
} from "./propertyPanelFxPresetStyle.js";
910

@@ -58,21 +59,57 @@ describe("per-preset title treatments", () => {
5859
}
5960
});
6061

61-
it("keeps every colour in one narrow lightness band", () => {
62-
// A per-preset colour free-for-all would read as status. These sit where the
63-
// family tints do, and the panel already spends saturation on "automated"
64-
// and "bypassed".
62+
it("keeps every colour vibrant, and light enough to read on the panel", () => {
63+
// The rack sits on #0C0C0E. A title has to carry real colour to be worth
64+
// having, and still clear contrast against near-black.
6565
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
66-
const match = /hsl\(\s*\d+,\s*(\d+)%,\s*(\d+)%\s*\)/.exec(style.color);
66+
const match = /hsl\(\s*(\d+),\s*(\d+)%,\s*(\d+)%\s*\)/.exec(style.color);
6767
expect(match, `${id} is not a plain hsl() colour`).toBeTruthy();
68-
const saturation = Number(match?.[1]);
69-
const lightness = Number(match?.[2]);
70-
expect(saturation, `${id} is too saturated to be type`).toBeLessThanOrEqual(50);
71-
expect(lightness, `${id} is too dark to read on the panel`).toBeGreaterThanOrEqual(66);
72-
expect(lightness, `${id} is too light to sit beside the others`).toBeLessThanOrEqual(78);
68+
const saturation = Number(match?.[1 + 1]);
69+
const lightness = Number(match?.[3]);
70+
expect(saturation, `${id} is too washed out to read as a colour`).toBeGreaterThanOrEqual(60);
71+
expect(lightness, `${id} is too dark against the panel`).toBeGreaterThanOrEqual(58);
72+
expect(lightness, `${id} is so light the hue disappears`).toBeLessThanOrEqual(78);
7373
}
7474
});
7575

76+
it("keeps the title hues clear of the accent, which means something else", () => {
77+
// The panel spends #3CE6AC (hue 160) on "automated" and "playing". A title
78+
// sitting on that hue reads as a status the preset does not have.
79+
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
80+
const hue = Number(/hsl\(\s*(\d+),/.exec(style.color)?.[1]);
81+
const distance = Math.min(Math.abs(hue - 160), 360 - Math.abs(hue - 160));
82+
expect(distance, `${id} sits on the accent's hue`).toBeGreaterThan(20);
83+
}
84+
});
85+
86+
it("backs each preset with its own hue, dark enough to sit under the panel", () => {
87+
// Derived from the title rather than picked, so a background cannot drift
88+
// away from the title it belongs to.
89+
const seen = new Set<string>();
90+
for (const [id, style] of Object.entries(FX_PRESET_STYLE)) {
91+
const bg = fxPresetBackground(id);
92+
expect(bg, `${id} has no background`).toBeTruthy();
93+
const titleHue = /hsl\(\s*(\d+),/.exec(style.color)?.[1];
94+
expect(bg, `${id}'s background is a different hue from its title`).toContain(
95+
`hsl(${titleHue},`,
96+
);
97+
const lightness = Number(/,\s*(\d+)%\s*\)/.exec(bg ?? "")?.[1]);
98+
expect(lightness, `${id}'s background would fight the controls on it`).toBeLessThanOrEqual(
99+
16,
100+
);
101+
seen.add(bg ?? "");
102+
}
103+
// Presets that share a title colour share a background — the repair family
104+
// is deliberately uniform — but the character ones must not.
105+
const character = HF_AUDIO_FX_PRESETS.filter((p) => p.family === "character");
106+
expect(new Set(character.map((p) => fxPresetBackground(p.id))).size).toBe(character.length);
107+
});
108+
109+
it("has no background for a preset it does not know", () => {
110+
expect(fxPresetBackground("not-a-preset")).toBeNull();
111+
});
112+
76113
it("falls back rather than failing for a preset it does not know", () => {
77114
expect(fxPresetStyle("not-a-preset")).toBe(FX_PRESET_STYLE_DEFAULT);
78115
});

‎packages/studio/src/components/editor/propertyPanelFxPresetStyle.ts‎

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ const FACE = {
6060
/** Plain, and what any preset without its own entry gets. */
6161
export const FX_PRESET_STYLE_DEFAULT: FxPresetStyle = {
6262
type: "text-[12px] uppercase tracking-wide",
63-
color: "hsl(220, 12%, 68%)",
63+
color: "hsl(220, 24%, 72%)",
6464
family: FACE.terminal,
6565
};
6666

@@ -71,115 +71,137 @@ export const FX_PRESET_STYLE: Record<string, FxPresetStyle> = {
7171
// face, because "no styling at all" is what every other row in the panel has.
7272
"voice-clean": {
7373
type: "text-[13px] font-medium tracking-tight",
74-
color: "hsl(155, 34%, 70%)",
74+
color: "hsl(202, 82%, 66%)",
7575
family: FACE.geometric,
7676
},
7777
"voice-broadcast": {
7878
type: "text-[13px] font-bold uppercase tracking-[0.14em]",
79-
color: "hsl(155, 34%, 74%)",
79+
color: "hsl(214, 84%, 70%)",
8080
family: FACE.editorial,
8181
},
8282
"voice-warm": {
8383
type: "text-[14px] italic tracking-normal",
84-
color: "hsl(28, 40%, 74%)",
84+
color: "hsl(32, 88%, 66%)",
8585
family: FACE.bookish,
8686
},
8787

8888
// --- repair: workshop labels. Fixed, plain, nothing decorative ------------
8989
"rumble-cut": {
9090
type: "text-[12px] uppercase tracking-[0.18em]",
91-
color: "hsl(205, 28%, 70%)",
91+
color: "hsl(196, 78%, 64%)",
9292
family: FACE.terminal,
9393
},
9494
"room-gate": {
9595
type: "text-[12px] uppercase tracking-[0.18em]",
96-
color: "hsl(205, 28%, 70%)",
96+
color: "hsl(196, 78%, 64%)",
9797
family: FACE.terminal,
9898
},
9999
"boom-tame": {
100100
type: "text-[12px] uppercase tracking-[0.18em]",
101-
color: "hsl(205, 28%, 70%)",
101+
color: "hsl(196, 78%, 64%)",
102102
family: FACE.terminal,
103103
},
104104
"harsh-tame": {
105105
type: "text-[12px] uppercase tracking-[0.18em]",
106-
color: "hsl(205, 28%, 70%)",
106+
color: "hsl(196, 78%, 64%)",
107107
family: FACE.terminal,
108108
},
109109

110110
// --- character: the costumes. This is where type does the work ------------
111111
// A phone's band is narrow; so is the tracking, on a face with no warmth.
112112
telephone: {
113113
type: "text-[13px] uppercase tracking-[0.3em]",
114-
color: "hsl(190, 34%, 70%)",
114+
color: "hsl(186, 85%, 62%)",
115115
family: FACE.terminal,
116116
},
117117
// A dial face: high-contrast serif caps, spaced like printed frequencies.
118118
"radio-am": {
119119
type: "text-[14px] uppercase tracking-[0.24em]",
120-
color: "hsl(38, 40%, 72%)",
120+
color: "hsl(42, 92%, 62%)",
121121
family: FACE.editorial,
122122
},
123123
// Shouted through a horn — the heaviest, narrowest thing available, leaning.
124124
megaphone: {
125125
type: "text-[17px] font-black italic uppercase tracking-tight",
126-
color: "hsl(14, 46%, 70%)",
126+
color: "hsl(12, 90%, 64%)",
127127
family: FACE.condensed,
128128
},
129129
// Struck on a machine, played back years later.
130130
"lofi-tape": {
131131
type: "text-[13px] tracking-wide",
132-
color: "hsl(36, 30%, 68%)",
132+
color: "hsl(28, 72%, 62%)",
133133
family: FACE.typewriter,
134134
},
135135
// Bolted to a wall in a station concourse.
136136
"pa-system": {
137137
type: "text-[13px] uppercase tracking-[0.26em]",
138-
color: "hsl(210, 26%, 72%)",
138+
color: "hsl(222, 80%, 70%)",
139139
family: FACE.engraved,
140140
},
141141
// Small, squeezed through a grille, no room for anything but the letters.
142142
intercom: {
143143
type: "text-[12px] font-bold uppercase tracking-tighter",
144-
color: "hsl(96, 26%, 68%)",
144+
color: "hsl(96, 68%, 60%)",
145145
family: FACE.terminal,
146146
},
147147
// The name is a joke and the type is in on it.
148148
"doofus-worble": {
149149
type: "text-[16px] tracking-[0.1em]",
150-
color: "hsl(286, 38%, 74%)",
150+
color: "hsl(288, 85%, 72%)",
151151
family: FACE.theatrical,
152152
},
153153

154154
// --- space: rooms. Light and wide, because that is what space looks like ---
155155
"room-tight": {
156156
type: "text-[13px] uppercase tracking-[0.2em]",
157-
color: "hsl(250, 26%, 72%)",
157+
color: "hsl(252, 74%, 72%)",
158158
family: FACE.geometric,
159159
},
160160
"room-natural": {
161161
type: "text-[13px] uppercase tracking-[0.26em]",
162-
color: "hsl(250, 26%, 74%)",
162+
color: "hsl(258, 78%, 72%)",
163163
family: FACE.geometric,
164164
},
165165
// The biggest room gets the widest setting — the word itself opens out.
166166
hall: {
167167
type: "text-[15px] uppercase tracking-[0.4em]",
168-
color: "hsl(250, 28%, 76%)",
168+
color: "hsl(246, 84%, 74%)",
169169
family: FACE.engraved,
170170
},
171171
"slap-echo": {
172172
type: "text-[13px] uppercase tracking-[0.28em]",
173-
color: "hsl(268, 30%, 72%)",
173+
color: "hsl(274, 76%, 70%)",
174174
family: FACE.geometric,
175175
},
176176
"dub-throw": {
177177
type: "text-[14px] italic tracking-[0.3em]",
178-
color: "hsl(268, 34%, 74%)",
178+
color: "hsl(312, 78%, 70%)",
179179
family: FACE.editorial,
180180
},
181181
};
182182

183183
export function fxPresetStyle(presetId: string): FxPresetStyle {
184184
return FX_PRESET_STYLE[presetId] ?? FX_PRESET_STYLE_DEFAULT;
185185
}
186+
187+
/**
188+
* The wash behind a preset's bracket, derived from its title colour.
189+
*
190+
* Derived rather than picked: nineteen hand-chosen pairs is nineteen chances
191+
* for 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+
* Returns a CSS colour, or null when the preset has no character of its own.
197+
*/
198+
export function fxPresetBackground(presetId: string): string | null {
199+
const style = FX_PRESET_STYLE[presetId];
200+
if (!style) return null;
201+
const hsl = /hsl\(\s*(\d+),\s*(\d+)%,\s*(\d+)%\s*\)/.exec(style.color);
202+
if (!hsl) return null;
203+
const hue = hsl[1];
204+
// 22% saturation at 11% lightness: present enough to tell two brackets apart
205+
// at a glance, dark enough that white body text still clears WCAG AA on it.
206+
return `hsl(${hue}, 22%, 11%)`;
207+
}

‎packages/studio/src/components/editor/propertyPanelFxSection.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
type HfAudioFxJob,
3636
} from "@hyperframes/core/audio-fx-jobs";
3737
import { FxParamRow } from "./propertyPanelFxControls.js";
38-
import { fxPresetStyle } from "./propertyPanelFxPresetStyle.js";
38+
import { fxPresetBackground, fxPresetStyle } from "./propertyPanelFxPresetStyle.js";
3939
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
4040
import { FxEqModule } from "./propertyPanelFxEqModule.js";
4141
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
@@ -649,15 +649,21 @@ export function FxSection({
649649
const runKey = `${run.preset}-${run.items[0]?.i ?? 0}`;
650650
const collapsed = collapsedRuns.has(runKey);
651651
const style = fxPresetStyle(run.preset ?? "");
652+
const background = fxPresetBackground(run.preset ?? "");
652653
return (
653654
<div
654655
key={`preset-${run.preset}-${run.items[0]?.i}`}
655656
className="hf-fx-preset-run space-y-1 rounded-[4px] border border-l-2 border-dashed border-panel-border-input p-1"
656657
data-fx-preset={run.preset}
657658
data-collapsed={collapsed ? "" : undefined}
658659
// The bracket's edge carries the preset's own colour, the way a
659-
// module's carries its family's.
660-
style={{ borderLeftColor: style.color }}
660+
// module's carries its family's — and the wash behind it is the
661+
// same hue taken to near-black, so a rack with three presets in
662+
// it reads as three regions rather than one long list.
663+
style={{
664+
borderLeftColor: style.color,
665+
...(background ? { backgroundColor: background } : {}),
666+
}}
661667
>
662668
<div className="hf-fx-preset-run-head flex min-h-6 items-center gap-1 px-0.5">
663669
<button

0 commit comments

Comments
 (0)