fix(sound): stop component imports from dragging audio assets into consumer bundles

Importing any sound-aware component (e.g. @unom/ui/button) statically
reached src/sound/defaults.ts via the provider, pulling ~6.7MB of wav/mp3
URL references into every consumer build even with no SoundProvider mounted.

- new src/sound/silent.ts: asset-free silentSoundTheme, now the context default
- SoundTheme.tokens is Partial<> so sparse themes typecheck; merge/resolve
  are null-safe over missing tokens
- SoundProvider: theme prop now optional; new loadTheme prop resolves a
  theme chunk lazily after mount and merges it over theme
- defaults.ts renamed to default-theme.ts, no longer re-exported from
  sound/index.ts; published as the @unom/ui/sound/default-theme subpath
  (the ONLY module referencing the audio assets)
- Storybook preview passes defaultSoundTheme explicitly via the new subpath

Verified: after build, grep -rl "assets/sounds" dist/ lists only
dist/sound/default-theme.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:34:11 +02:00
co-authored by Claude Fable 5
parent 84f66e9fae
commit e50e747839
8 changed files with 89 additions and 85 deletions
+2 -1
View File
@@ -2,6 +2,7 @@ import "../src/styles/theme.css";
import { definePreview } from "@storybook/react-vite";
import { AnimationProvider } from "@/animation/provider";
import Section from "@/section";
import { defaultSoundTheme } from "@/sound/default-theme";
import { SoundProvider } from "@/sound/provider";
export default definePreview({
@@ -9,7 +10,7 @@ export default definePreview({
decorators: [
(Story) => (
<AnimationProvider theme={{}}>
<SoundProvider theme={{}}>
<SoundProvider theme={defaultSoundTheme}>
<Section maxWidth={false}>
<Story />
</Section>
+2 -1
View File
@@ -68,6 +68,7 @@
"./lib/utils": "./dist/lib/utils.js",
"./preload-reload": "./dist/preload-reload.js",
"./sound": "./dist/sound/index.js",
"./sound/default-theme": "./dist/sound/default-theme.js",
"./richtext": "./dist/richtext/index.js",
"./richtext/converters": "./dist/richtext/converters/index.js",
"./richtext/converters/headings": "./dist/richtext/converters/headings.js",
@@ -98,4 +99,4 @@
"motion-dom": "12.38.0",
"motion-utils": "12.36.0"
}
}
}
-67
View File
@@ -1,67 +0,0 @@
import type { SoundDef, SoundTheme } from "./types";
const buttonsSrc = new URL(
"../assets/sounds/762132__ienba__ui-buttons.wav",
import.meta.url,
).href;
const uiSetSrc = new URL(
"../assets/sounds/842498__newlocknew__uimvmt_game-user-interface-sound-set.mp3",
import.meta.url,
).href;
const buttonsSpriteMap = {
click1: [0, 1000],
click2: [2750, 1000],
click3: [5200, 1000],
click4: [7700, 1000],
} satisfies Record<string, [number, number]>;
const uiSetSpriteMap = {
smooth1: [0, 1200],
smooth2: [7800, 1400],
lobbyCreated: [53800, 3500],
gameStart: [62000, 4000],
} satisfies Record<string, [number, number]>;
const fromButtons = (
sprite: keyof typeof buttonsSpriteMap,
extra?: Partial<SoundDef>,
): SoundDef => ({
src: buttonsSrc,
sprite,
spriteMap: buttonsSpriteMap,
...extra,
});
const fromUiSet = (
sprite: keyof typeof uiSetSpriteMap,
extra?: Partial<SoundDef>,
): SoundDef => ({
src: uiSetSrc,
sprite,
spriteMap: uiSetSpriteMap,
...extra,
});
export const defaultSoundTheme: SoundTheme = {
volume: 1,
muted: false,
custom: {},
tokens: {
click: fromButtons("click1", { pool: 4, interrupt: true }),
hover: fromButtons("click2", { pool: 4, interrupt: true, volume: 0.6 }),
toggle: fromButtons("click2", { pool: 2 }),
focus: fromButtons("click2", { pool: 1, volume: 0.5 }),
selectOpen: fromButtons("click3"),
selectClose: fromButtons("click4"),
lobbyCreated: fromUiSet("lobbyCreated"),
gameStart: fromUiSet("gameStart"),
gameWon: fromUiSet("gameStart"),
roundSuccess: fromUiSet("smooth1"),
userJoined: fromUiSet("smooth1", { volume: 0.7 }),
userLeft: fromUiSet("smooth2", { volume: 0.7 }),
error: fromButtons("click4", { volume: 0.8 }),
submit: fromButtons("click3"),
vote: fromButtons("click2"),
},
};
+4 -1
View File
@@ -1,4 +1,6 @@
export { defaultSoundTheme } from "./defaults";
// NOTE: `defaultSoundTheme` is deliberately NOT re-exported here — it is the
// only module referencing the (multi-MB) audio assets and must stay behind the
// `@unom/ui/sound/default-theme` subpath so component imports stay asset-free.
export { mergeTheme } from "./merge";
export {
SoundProvider,
@@ -8,6 +10,7 @@ export {
useSoundSettings,
useSoundTheme,
} from "./provider";
export { silentSoundTheme } from "./silent";
export {
type AnySoundKey,
type CoreSoundKey,
+4 -5
View File
@@ -4,11 +4,10 @@ export function mergeTheme(
base: SoundTheme,
override: PartialSoundTheme,
): SoundTheme {
const tokens = { ...base.tokens };
// Both sides may be sparse — missing keys simply stay silent.
const tokens = { ...(base.tokens ?? {}) };
if (override.tokens) {
for (const key of Object.keys(
override.tokens,
) as (keyof typeof tokens)[]) {
for (const key of Object.keys(override.tokens) as (keyof typeof tokens)[]) {
const next = override.tokens[key];
// `undefined` inherits from parent; explicit `null` silences this token.
if (next !== undefined) tokens[key] = next;
@@ -18,6 +17,6 @@ export function mergeTheme(
tokens,
volume: override.volume ?? base.volume,
muted: override.muted ?? base.muted,
custom: { ...base.custom, ...(override.custom ?? {}) },
custom: { ...(base.custom ?? {}), ...(override.custom ?? {}) },
};
}
+60 -9
View File
@@ -8,9 +8,14 @@ import {
useRef,
useState,
} from "react";
import { defaultSoundTheme } from "./defaults";
import { mergeTheme } from "./merge";
import { getUrlHowl, playDef, stopAll as stopAllPlayers, stopDef } from "./player";
import {
getUrlHowl,
playDef,
stopAll as stopAllPlayers,
stopDef,
} from "./player";
import { silentSoundTheme } from "./silent";
import type {
AnySoundKey,
CoreSoundKey,
@@ -22,7 +27,10 @@ import type {
SoundTheme,
} from "./types";
const SoundContext = createContext<SoundTheme>(defaultSoundTheme);
// Silent by default: the default theme (and its audio assets) must only ever
// be pulled in through the `@unom/ui/sound/default-theme` subpath, never by
// merely importing a sound-aware component.
const SoundContext = createContext<SoundTheme>(silentSoundTheme);
const ControllerContext = createContext<SoundController | null>(null);
type PersistedSettings = { volume?: number; muted?: boolean };
@@ -50,7 +58,22 @@ function writePersisted(key: string, value: PersistedSettings) {
}
export type SoundProviderProps = {
theme: PartialSoundTheme;
theme?: PartialSoundTheme;
/**
* Lazily loaded theme, resolved once after mount and merged OVER `theme`.
* Lets apps defer even the theme's JS (and its audio asset references):
*
* ```tsx
* <SoundProvider
* loadTheme={() =>
* import("@unom/ui/sound/default-theme").then((m) => m.defaultSoundTheme)
* }
* >
* ```
*
* Read once on mount, so an inline arrow function is fine.
*/
loadTheme?: () => Promise<PartialSoundTheme>;
/** When set, volume/mute persist to localStorage under this key. */
persistKey?: string;
children: ReactNode;
@@ -58,6 +81,7 @@ export type SoundProviderProps = {
export function SoundProvider({
theme,
loadTheme,
persistKey,
children,
}: SoundProviderProps) {
@@ -72,6 +96,30 @@ export function SoundProvider({
Pick<PartialSoundTheme, "volume" | "muted">
>({});
const [hydrated, setHydrated] = useState(false);
const [loadedTheme, setLoadedTheme] = useState<PartialSoundTheme | null>(
null,
);
// `loadTheme` is intentionally read once on mount (via ref) so consumers can
// pass an inline arrow without re-triggering the load on every render.
const loadThemeRef = useRef(loadTheme);
loadThemeRef.current = loadTheme;
useEffect(() => {
const load = loadThemeRef.current;
if (!load) return;
let cancelled = false;
load()
.then((t) => {
if (!cancelled) setLoadedTheme(t);
})
.catch(() => {
// Failed/aborted chunk load — stay silent rather than crash.
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (persistKey) {
@@ -90,10 +138,12 @@ export function SoundProvider({
});
}, [persistKey, hydrated, overrides.volume, overrides.muted]);
const merged = useMemo(
() => mergeTheme(parent, { ...theme, ...overrides }),
[parent, theme, overrides],
);
const merged = useMemo(() => {
// parent ← theme ← loadTheme() result ← persisted volume/mute overrides
let next = mergeTheme(parent, theme ?? {});
if (loadedTheme) next = mergeTheme(next, loadedTheme);
return mergeTheme(next, overrides);
}, [parent, theme, loadedTheme, overrides]);
// Stable themeRef so controller callbacks never need to re-create.
const themeRef = useRef(merged);
@@ -102,7 +152,8 @@ export function SoundProvider({
const controller = useMemo<SoundController>(() => {
const resolve = (key: string): SoundDef | null => {
const t = themeRef.current;
if (key in t.tokens) return t.tokens[key as CoreSoundKey];
// Sparse tokens: a missing key falls through to `custom`, then silence.
if (key in t.tokens) return t.tokens[key as CoreSoundKey] ?? null;
return t.custom[key] ?? null;
};
return {
+14
View File
@@ -0,0 +1,14 @@
import type { SoundTheme } from "./types";
/**
* The zero-asset theme. Used as the context default so that importing any
* sound-aware component (e.g. Button) never drags audio assets into a
* consumer's bundle. Mount a <SoundProvider> with a real theme (or
* `loadTheme`) to opt into sound.
*/
export const silentSoundTheme: SoundTheme = {
tokens: {},
volume: 1,
muted: false,
custom: {},
};
+3 -1
View File
@@ -33,11 +33,13 @@ export type CoreSoundKey =
| "vote";
/** Games augment this via `declare module "@unom/ui/sound"` to register custom token names. */
// biome-ignore lint/suspicious/noEmptyInterface: must stay an interface — consumers extend it via declaration merging.
export interface SoundCustomTokens {}
export type CustomSoundKey = Extract<keyof SoundCustomTokens, string>;
export type SoundTheme = {
tokens: Record<CoreSoundKey, SoundDef | null>;
/** Sparse: missing keys are simply silent. `null` explicitly silences a token. */
tokens: Partial<Record<CoreSoundKey, SoundDef | null>>;
volume: number;
muted: boolean;
custom: Record<string, SoundDef | null>;