diff --git a/apps/web/public/audio/pig-ambient.mp3 b/apps/web/public/audio/pig-ambient.mp3
new file mode 100644
index 0000000..8b4911b
Binary files /dev/null and b/apps/web/public/audio/pig-ambient.mp3 differ
diff --git a/apps/web/public/audio/pig-jazz.mp3 b/apps/web/public/audio/pig-jazz.mp3
new file mode 100644
index 0000000..9d47f6b
Binary files /dev/null and b/apps/web/public/audio/pig-jazz.mp3 differ
diff --git a/apps/web/public/audio/pig-tech.mp3 b/apps/web/public/audio/pig-tech.mp3
new file mode 100644
index 0000000..d3d1250
Binary files /dev/null and b/apps/web/public/audio/pig-tech.mp3 differ
diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx
index 59e30b7..e186f95 100644
--- a/apps/web/src/components/AppHeader.tsx
+++ b/apps/web/src/components/AppHeader.tsx
@@ -26,6 +26,7 @@ import { activeNavItem, visibleNav } from '@/lib/nav';
import { CommandPalette } from './CommandPalette';
import { PiggyLogo } from './PiggyMark';
import { PiggyDockToggle } from './PiggyDock';
+import { AudioControl } from './AudioControl';
import { Button, cn } from './ui';
import {
Breadcrumb,
@@ -115,6 +116,7 @@ export function AppHeader() {
+
diff --git a/apps/web/src/components/AudioControl.tsx b/apps/web/src/components/AudioControl.tsx
new file mode 100644
index 0000000..602e0cf
--- /dev/null
+++ b/apps/web/src/components/AudioControl.tsx
@@ -0,0 +1,80 @@
+/**
+ * The music control in the header.
+ *
+ * A single button toggles it; the caret opens the track list. The button's
+ * label distinguishes three states rather than two, because "on but waiting
+ * for a click" is a real state the browser puts us in and a speaker icon that
+ * claims to be playing when nothing is audible is the confusing part.
+ */
+import { ChevronDown, Music, Volume2, VolumeX } from 'lucide-react';
+import { PLATFORM_TRACKS, usePlatformAudio } from '@/lib/audio';
+import { Button, cn } from './ui';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from './ui/dropdown-menu';
+
+export function AudioControl({ className }: { className?: string }) {
+ const audio = usePlatformAudio();
+ if (!audio) return null;
+
+ const { enabled, playing, track, toggle, setTrack } = audio;
+ const current = PLATFORM_TRACKS.find((entry) => entry.id === track);
+ const label = !enabled
+ ? 'Play platform music'
+ : playing
+ ? `Mute platform music (${current?.label})`
+ : 'Music starts when you interact with the page';
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/Shell.tsx b/apps/web/src/components/Shell.tsx
index e6a1a1b..60c60b2 100644
--- a/apps/web/src/components/Shell.tsx
+++ b/apps/web/src/components/Shell.tsx
@@ -29,6 +29,7 @@ import { NavLink } from 'react-router-dom';
import { useIdentity } from '@/lib/identity';
import { useLayout } from '@/lib/layout';
import { visibleNav, type NavItem } from '@/lib/nav';
+import { PlatformAudioProvider } from '@/lib/audio';
import { AppHeader } from './AppHeader';
import { AppSidebar } from './AppSidebar';
import { PiggyDock } from './PiggyDock';
@@ -44,6 +45,14 @@ export function Shell() {
const items = visibleNav(identity);
return (
+ /*
+ * Music is mounted HERE rather than in App.tsx, so it wraps only the
+ * signed-in application. The anonymous Learn page renders outside Shell,
+ * and a share-code visitor opening a link someone sent them should not get
+ * unexpected audio — that is the one context where it reads as a fault
+ * rather than as character.
+ */
+ item.primary)} />
+
);
}
diff --git a/apps/web/src/lib/audio.tsx b/apps/web/src/lib/audio.tsx
new file mode 100644
index 0000000..9f06f9b
--- /dev/null
+++ b/apps/web/src/lib/audio.tsx
@@ -0,0 +1,208 @@
+/**
+ * Platform music.
+ *
+ * ## Autoplay does not work the way the word suggests
+ *
+ * Every current browser blocks audio *with sound* until the page has received
+ * a real user gesture. Chrome will sometimes allow it on a site the user
+ * visits constantly (its Media Engagement Index); Safari essentially never
+ * does on a first visit. So `audio.play()` on mount returns a promise that
+ * REJECTS, and a naive implementation looks like a bug: the toggle says
+ * playing, nothing is audible, and the console has one line about it.
+ *
+ * The honest behaviour, and what this does: try immediately, and if the browser
+ * refuses, arm a one-shot listener and start on the first click or keypress.
+ * The effect is that music begins when someone actually starts using the app
+ * rather than the instant a tab opens — which is also the kinder behaviour for
+ * anyone who opened five tabs at once.
+ *
+ * ## Per-device, not per-account
+ *
+ * Stored in localStorage and deliberately not mirrored to the server, for the
+ * same reason the sidebar collapse is not: whether you want music depends on
+ * whether you are wearing headphones, not on who you are. Theme is mirrored;
+ * this is not. See lib/layout.tsx.
+ */
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
+
+export const PLATFORM_TRACKS = [
+ { id: 'ambient', label: 'Ambient', src: '/audio/pig-ambient.mp3' },
+ { id: 'jazz', label: 'Jazz', src: '/audio/pig-jazz.mp3' },
+ { id: 'tech', label: 'Tech', src: '/audio/pig-tech.mp3' },
+] as const;
+
+export type PlatformTrackId = (typeof PLATFORM_TRACKS)[number]['id'];
+
+const ENABLED_KEY = 'pig.audio.enabled';
+const TRACK_KEY = 'pig.audio.track';
+
+/**
+ * Low, and deliberately so. The source files are mastered at about -26 LUFS
+ * and this sits under them, because the failure mode of background music in a
+ * tool someone has open for eight hours is not "too quiet".
+ */
+const VOLUME = 0.14;
+
+function storedTrack(): PlatformTrackId {
+ try {
+ const raw = localStorage.getItem(TRACK_KEY);
+ if (PLATFORM_TRACKS.some((track) => track.id === raw)) return raw as PlatformTrackId;
+ } catch {
+ /* Private browsing throws on localStorage. The default is fine. */
+ }
+ return 'ambient';
+}
+
+function storedEnabled(): boolean {
+ try {
+ // Default ON. An explicit "false" is the only thing that turns it off, so a
+ // cleared storage does not silently change the product's character.
+ return localStorage.getItem(ENABLED_KEY) !== 'false';
+ } catch {
+ return true;
+ }
+}
+
+interface PlatformAudio {
+ enabled: boolean;
+ /** True only when sound is actually coming out — see the autoplay note. */
+ playing: boolean;
+ track: PlatformTrackId;
+ toggle: () => void;
+ setTrack: (id: PlatformTrackId) => void;
+}
+
+const Context = createContext(null);
+
+export function PlatformAudioProvider({ children }: { children: ReactNode }) {
+ const [enabled, setEnabled] = useState(storedEnabled);
+ const [track, setTrackState] = useState(storedTrack);
+ const [playing, setPlaying] = useState(false);
+ const elementRef = useRef(null);
+
+ const source = PLATFORM_TRACKS.find((entry) => entry.id === track) ?? PLATFORM_TRACKS[0];
+
+ useEffect(() => {
+ const audio = elementRef.current;
+ if (!audio) return;
+
+ if (!enabled) {
+ audio.pause();
+ setPlaying(false);
+ return;
+ }
+
+ audio.volume = VOLUME;
+ let cancelled = false;
+ let armed: (() => void) | null = null;
+
+ const start = () =>
+ audio
+ .play()
+ .then(() => {
+ if (!cancelled) setPlaying(true);
+ })
+ .catch(() => false);
+
+ void start().then((ok) => {
+ if (ok !== false || cancelled) return;
+ // Refused for want of a gesture. Wait for one rather than reporting a
+ // state that is not true.
+ const onGesture = () => {
+ void start();
+ detach();
+ };
+ const detach = () => {
+ window.removeEventListener('pointerdown', onGesture);
+ window.removeEventListener('keydown', onGesture);
+ armed = null;
+ };
+ armed = detach;
+ window.addEventListener('pointerdown', onGesture, { once: true });
+ window.addEventListener('keydown', onGesture, { once: true });
+ });
+
+ return () => {
+ cancelled = true;
+ armed?.();
+ };
+ }, [enabled, source.src]);
+
+ // Pause when the tab is hidden. Music from a tab nobody is looking at is the
+ // thing people hunt through twenty tabs to kill.
+ useEffect(() => {
+ const onVisibility = () => {
+ const audio = elementRef.current;
+ if (!audio || !enabled) return;
+ if (document.hidden) {
+ audio.pause();
+ setPlaying(false);
+ } else {
+ void audio.play().then(() => setPlaying(true)).catch(() => {});
+ }
+ };
+ document.addEventListener('visibilitychange', onVisibility);
+ return () => document.removeEventListener('visibilitychange', onVisibility);
+ }, [enabled]);
+
+ useEffect(() => () => elementRef.current?.pause(), []);
+
+ const toggle = useCallback(() => {
+ setEnabled((current) => {
+ const next = !current;
+ try {
+ localStorage.setItem(ENABLED_KEY, String(next));
+ } catch {
+ /* Nothing to do; the session still honours the choice. */
+ }
+ return next;
+ });
+ }, []);
+
+ const setTrack = useCallback((id: PlatformTrackId) => {
+ setTrackState(id);
+ try {
+ localStorage.setItem(TRACK_KEY, id);
+ } catch {
+ /* As above. */
+ }
+ }, []);
+
+ const value = useMemo(
+ () => ({ enabled, playing, track, toggle, setTrack }),
+ [enabled, playing, track, toggle, setTrack],
+ );
+
+ return (
+
+ {/*
+ A real element in the tree rather than `new Audio()`, so it is
+ inspectable in devtools and in a test. `preload="none"` keeps a user
+ who never enables music from downloading a track.
+ */}
+
+ {children}
+
+ );
+}
+
+/** Null outside the provider, so the anonymous Learn page stays silent. */
+export function usePlatformAudio(): PlatformAudio | null {
+ return useContext(Context);
+}