From 807950405fc88348d7ebe201e22f2e3da8604805 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:08:34 -0700 Subject: [PATCH] Give phones a listening player instead of the shrunken panel Phones are for listening to loops. Under 700 px the app now shows a one-column player: the nameplate, the loop's name and tempo, a read-only step grid that lights up as it plays, one big START/STOP key, and LOOP keys for the six patterns. Shared links open in it. The nameplate, chrome housing, and pattern list move into shared modules used by both layouts. Safari on iPhone now plays with the silent switch on. Co-Authored-By: Claude Opus 5.5 --- .claude/CLAUDE.md | 1 + README.md | 1 + e2e/smoke.spec.ts | 22 ++++++ src/App.module.css | 7 ++ src/App.tsx | 18 ++++- src/engine/engine.ts | 3 + src/ui/Housing.module.css | 22 ++++++ src/ui/LoopMachine.module.css | 49 +----------- src/ui/LoopMachine.tsx | 47 ++---------- src/ui/Nameplate.module.css | 30 ++++++++ src/ui/Nameplate.tsx | 37 +++++++++ src/ui/Player.module.css | 137 ++++++++++++++++++++++++++++++++++ src/ui/Player.test.tsx | 43 +++++++++++ src/ui/Player.tsx | 84 +++++++++++++++++++++ src/ui/fire.ts | 5 ++ src/ui/patterns.ts | 12 +++ src/ui/tokens.css | 7 ++ src/ui/useMediaQuery.ts | 13 ++++ 18 files changed, 453 insertions(+), 85 deletions(-) create mode 100644 src/ui/Housing.module.css create mode 100644 src/ui/Nameplate.module.css create mode 100644 src/ui/Nameplate.tsx create mode 100644 src/ui/Player.module.css create mode 100644 src/ui/Player.test.tsx create mode 100644 src/ui/Player.tsx create mode 100644 src/ui/fire.ts create mode 100644 src/ui/patterns.ts create mode 100644 src/ui/useMediaQuery.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 961eb6c..9291962 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -16,6 +16,7 @@ src/ui/ React components, CSS Modules, tokens.css, vendored fonts ``` - **One session document** is the source of truth. UI dispatches `Command`s; `applyCommand` is pure and tested. The store keeps undo history (knob drags coalesce into one step), notifies the engine (`engine.apply`), and rewrites the share link. +- **Layouts:** windows wider than 700 px get the full `LoopMachine` panel, scaled to fit; phones get `Player`, a listening-only view (start/stop, loop picker, a read-only step grid). Both share `Nameplate`, `Housing.module.css`, and `patterns.ts`. - **Exploring:** `src/session/library.ts` holds the ready-made patterns (the app opens on the first) and the chords. Holding the first key with no synth steps fills in a default rhythm. - **Engine:** `Clock` schedules 100 ms ahead, ticked from a Worker so background tabs keep time. The sound is aimed at dirty French electro (Justice, Daft Punk, a little disco). `mixer.ts` builds the whole signal chain once. Drums are synthesized per hit in `drums.ts` (808 kick with a 909-style snap, driven 909 snare, clean disco open hat; no samples), each with a DECAY tail multiplier, and run through a `ChannelStrip` (volume → mix bus, with a send to the stereo ping-pong delay in `delay.ts`). The `Synth` is a seven-voice detuned stereo unison plus a sub, through a plucked low-pass, light drive, and a 7 kHz high cut; it starts voices at scheduled times and releases the previous arp note at the next note's start. The synth, delay, and reverb run through the `Pump`, which ducks on every kick like a sidechain; drums bypass it. Everything meets in the mix bus (gentle glue compressor with headroom) before a safety limiter. The playhead (`step`, fired `note`) is published when each step actually sounds. - **Share link (v2):** `?s=2~~~…~syn.<…>`, keyed by drum id. See `src/session/url.ts`. Malformed parts fall back to defaults. diff --git a/README.md b/README.md index e04d3d9..a8838f8 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A drum machine + synth in your browser, styled after the Roland TR-909. Program - **Controls:** knobs click to 11 notches and the fader to 9. Drag, click a notch, scroll, or use the arrow keys - **Computer keyboard:** A–C and W–\ toggle notes, like a piano layout - **Share links:** the URL holds the whole loop, drums and synth included +- **Phones:** a listening player that plays the shared loop or any of the six patterns; making loops needs a computer ## Run it diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index d5190c4..4ab5856 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -73,3 +73,25 @@ test('undo steps back a change', async ({ page }) => { await page.keyboard.press('ControlOrMeta+Z') await expect(step).toHaveAttribute('aria-pressed', 'false') }) + +test.describe('on a phone', () => { + test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }) + + test('shows a listening player that plays and switches loops', async ({ page }) => { + const errors = collectErrors(page) + await page.goto('/') + await expect(page.getByRole('button', { name: 'KICK step 1', exact: true })).toHaveCount(0) + await expect(page.getByText('Deep Night')).toBeVisible() + + await page.getByRole('button', { name: 'START' }).tap() + await expect(page.getByRole('button', { name: 'STOP' })).toBeVisible() + await expect(page.locator('[data-now="true"]').first()).toBeAttached() + await page.getByRole('button', { name: 'STOP' }).tap() + await expect(page.locator('[data-now="true"]')).toHaveCount(0) + + await page.getByRole('radio', { name: 'Warehouse' }).tap() + await expect(page.getByText('Warehouse')).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + expect(errors).toEqual([]) + }) +}) diff --git a/src/App.module.css b/src/App.module.css index 4928fed..aa4577b 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -1,3 +1,10 @@ .app { position: relative; } + +/* The phone player fills the width the page leaves it. */ +.phone { + display: flex; + justify-content: center; + width: 100%; +} diff --git a/src/App.tsx b/src/App.tsx index fcc1867..9a8330d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,12 +4,17 @@ import { DRUMS, noteForKey } from './instruments' import { createSessionStore } from './session/store' import { readSessionFromLocation, writeSessionToLocation } from './session/url' import { LoopMachine } from './ui/LoopMachine' +import { Player } from './ui/Player' +import { useMediaQuery } from './ui/useMediaQuery' import styles from './App.module.css' /** Panel width plus its glass border, and the page margin on each side. */ const PANEL_WIDTH = 1128 const PAGE_GUTTER = 48 +/** Phones get a listening player; the full panel needs room to edit. */ +const PHONE_QUERY = '(max-width: 700px)' + /** How long the share link may trail the session. Browsers rate-limit address rewrites, and a knob drag changes the session many times a second. */ const LINK_DELAY_MS = 250 @@ -56,6 +61,9 @@ export function App() { return () => window.removeEventListener('resize', fit) }, []) + const phone = useMediaQuery(PHONE_QUERY) + const onStartStop = () => (engine.isPlaying ? engine.stop() : void engine.start()) + // Computer keys toggle notes, like clicking the keyboard; ⌘Z / Ctrl+Z undoes. useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -76,13 +84,21 @@ export function App() { return () => window.removeEventListener('keydown', onKeyDown) }, [store]) + if (phone) { + return ( +
+ +
+ ) + } + return (
(engine.isPlaying ? engine.stop() : void engine.start())} + onStartStop={onStartStop} onReset={() => { engine.stop() store.dispatch({ type: 'reset' }) diff --git a/src/engine/engine.ts b/src/engine/engine.ts index b6d645b..13240ee 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -46,6 +46,9 @@ export class Engine { load(): void { if (this.graph) return const ctx = new AudioContext() + // Safari on iPhone silences Web Audio with the ring/silent switch unless the page says it plays media. + const audioSession = (navigator as Navigator & { audioSession?: { type: string } }).audioSession + if (audioSession) audioSession.type = 'playback' const graph: Graph = { ctx, ...buildMixer(ctx, this.drums), diff --git a/src/ui/Housing.module.css b/src/ui/Housing.module.css new file mode 100644 index 0000000..10b5b59 --- /dev/null +++ b/src/ui/Housing.module.css @@ -0,0 +1,22 @@ +/* The polished-chrome frame and the matte cream face inside it, shared by the panel and the phone player. */ +.housing { + padding: 8px; + border-radius: 20px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.55), transparent 18%, transparent 82%, rgba(0, 0, 0, 0.12)), + linear-gradient(90deg, #b9bcbf, #eef0f1 12%, #c9ccce 30%, #f6f7f8 50%, #c3c6c9 70%, #eceeef 88%, #b3b6b9); + box-shadow: + 0 30px 60px -28px rgba(32, 38, 56, 0.45), + 0 2px 6px rgba(32, 38, 56, 0.12), + 0 0 0 1px rgba(32, 38, 56, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.9), + inset 0 -1px 0 rgba(0, 0, 0, 0.15); +} + +.face { + border-radius: 13px; + background: #f7f4ee; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.9), + 0 0 0 1px rgba(0, 0, 0, 0.12); +} diff --git a/src/ui/LoopMachine.module.css b/src/ui/LoopMachine.module.css index f9c3599..009bd4e 100644 --- a/src/ui/LoopMachine.module.css +++ b/src/ui/LoopMachine.module.css @@ -1,29 +1,15 @@ -/* Glass housing over the wallpaper; the panel inside carries the 909 layout. */ -/* Polished-chrome housing frame, as on the original direction A, around the matte panel. */ +/* The chrome housing at full panel width; the face inside carries the 909 layout. */ .device { + composes: housing from './Housing.module.css'; width: 1128px; - padding: 8px; - border-radius: 20px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.55), transparent 18%, transparent 82%, rgba(0, 0, 0, 0.12)), - linear-gradient(90deg, #b9bcbf, #eef0f1 12%, #c9ccce 30%, #f6f7f8 50%, #c3c6c9 70%, #eceeef 88%, #b3b6b9); - box-shadow: - 0 30px 60px -28px rgba(32, 38, 56, 0.45), - 0 2px 6px rgba(32, 38, 56, 0.12), - 0 0 0 1px rgba(32, 38, 56, 0.18), - inset 0 1px 0 rgba(255, 255, 255, 0.9), - inset 0 -1px 0 rgba(0, 0, 0, 0.15); } .face { + composes: face from './Housing.module.css'; padding: 22px 28px 26px; - border-radius: 13px; - background: #f7f4ee; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.9), - 0 0 0 1px rgba(0, 0, 0, 0.12); } + /* Nameplate on the left; the global pattern and undo keys on the right, like the 909's pattern section. */ .top { display: flex; @@ -32,13 +18,6 @@ margin-bottom: 32px; } -/* "DRUM MACHINE + SYNTH" is sized to run exactly as wide as "LM-919". */ -.nameplate { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; -} .globals { display: flex; @@ -69,28 +48,8 @@ gap: 16px; } -.descriptor { - font: 400 9.5px/1 var(--font-nameplate); - letter-spacing: 0.16em; - color: var(--navy); - white-space: nowrap; -} -/* Carved into the panel and paint-filled; see the "carved" filter in LoopMachine.tsx. */ -.model { - margin: 0; - font: 400 34.53px/1 var(--font-nameplate); - letter-spacing: 0.05em; - color: var(--navy); - white-space: nowrap; - filter: url(#carved); -} -.defs { - position: absolute; - width: 0; - height: 0; -} .cols { display: grid; diff --git a/src/ui/LoopMachine.tsx b/src/ui/LoopMachine.tsx index 7f1ad0d..b67703b 100644 --- a/src/ui/LoopMachine.tsx +++ b/src/ui/LoopMachine.tsx @@ -1,15 +1,18 @@ -import { useCallback, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react' +import { useCallback, useSyncExternalStore, type ReactNode } from 'react' import { DRUMS, SYNTH_FIRE, type FireColor } from '../instruments' import type { Playhead } from '../engine/engine' -import { CHORDS, chordFor, PRESETS } from '../session/library' +import { CHORDS, chordFor } from '../session/library' import { OCTAVES, type ArpMode, type ArpRate, type Waveform } from '../session/schema' import { useSession, type SessionStore } from '../session/store' -import { decodeSession, encodeSession } from '../session/url' +import { decodeSession } from '../session/url' import { Fader } from './controls/Fader' import { Knob } from './controls/Knob' import { LedChoice, PlainKey } from './controls/LedButton' import { StepKey } from './controls/StepKey' +import { fireVars } from './fire' import { Keyboard } from './Keyboard' +import { Nameplate } from './Nameplate' +import { PATTERN_OPTIONS, presetFor } from './patterns' import { TempoControl } from './TempoControl' import styles from './LoopMachine.module.css' import transport from './Transport.module.css' @@ -22,8 +25,6 @@ interface LoopMachineProps { onReset: () => void } -const fireVars = (c: FireColor) => ({ '--fire-top': c.top, '--fire-bottom': c.bottom, '--fire-glow': c.glow }) as CSSProperties - /** Four groups of four steps, like the 909's beat groups. */ function StepGroups({ render }: { render: (step: number) => ReactNode }) { return ( @@ -55,8 +56,6 @@ const OCTAVE_OPTIONS = OCTAVES.map((o) => ({ name: o > 0 ? 'Octave up' : o < 0 ? 'Octave down' : 'Normal octave', })) -const PATTERN_OPTIONS = PRESETS.map((p, i) => ({ value: p.link, content: String(i + 1), name: p.name })) - const CHORD_OPTIONS = CHORDS.map((c) => ({ value: c.name, content: c.name, name: c.name })) const undoIcon = ( @@ -65,31 +64,6 @@ const undoIcon = ( ) -/** - * Makes the wordmark look cut into the panel: a shadow inside each stroke - * along its upper wall, and light catching the lower lip just outside it. - */ -const carvedFilter = ( - - - - - - - - - - - - - - - - - - -) - const wave = (d: string) => ( @@ -110,8 +84,7 @@ export function LoopMachine({ store, playhead, isPlaying, onStartStop, onReset } const { dispatch } = store const now = playhead.step // A pattern's LED stays lit until you change something. - const link = encodeSession(session) - const preset = PRESETS.find((p) => p.link === link) + const preset = presetFor(session) const toggleSynthStep = useCallback((step: number) => dispatch({ type: 'toggleSynthStep', step }), [dispatch]) const toggleNote = useCallback((note: string) => dispatch({ type: 'toggleNote', note }), [dispatch]) @@ -120,11 +93,7 @@ export function LoopMachine({ store, playhead, isPlaying, onStartStop, onReset }
-
- {carvedFilter} - DRUM MACHINE + SYNTH -

LM-919

-
+
+ + + + + + + + + + + + + + + + + +) + +/** The LM-919 nameplate: the descriptor over the carved, paint-filled wordmark. */ +export function Nameplate() { + return ( +
+ {carvedFilter} + DRUM MACHINE + SYNTH +

LM-919

+
+ ) +} diff --git a/src/ui/Player.module.css b/src/ui/Player.module.css new file mode 100644 index 0000000..241dd5e --- /dev/null +++ b/src/ui/Player.module.css @@ -0,0 +1,137 @@ +/* Phone listening player: the same chrome housing and 909 pieces, one column. */ +.player { + width: min(100%, 420px); + padding: 6px; + border-radius: 18px; +} + +.face { + display: flex; + flex-direction: column; + align-items: center; + gap: 22px; + padding: 20px 18px 22px; +} + +.face > :first-child { + align-self: flex-start; +} + +.now { + display: flex; + align-items: center; + justify-content: space-between; + align-self: stretch; + gap: 12px; +} + +.loopName { + overflow: hidden; + text-overflow: ellipsis; +} + +/* The tempo digits are a read-only span here, not the panel's input. */ +.digits { + position: absolute; + right: 10px; +} + +/* The loop running, read-only: one row per part, a cell per step. */ +.grid { + display: flex; + flex-direction: column; + gap: 8px; + align-self: stretch; +} + +.row { + display: grid; + grid-template-columns: 46px 1fr; + align-items: center; + gap: 8px; +} + +.rowLabel { + font: 800 9px/1 var(--font-label); + font-stretch: 108%; + letter-spacing: 0.06em; + color: var(--label); +} + +.beats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 7px; +} + +.beat { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 3px; +} + +.cell { + aspect-ratio: 1; + border-radius: 3px; + background: linear-gradient(#f2eee6, #ded9cf); + box-shadow: 0 0 0 1px rgba(42, 44, 47, 0.35); + transition: + background var(--fade), + box-shadow var(--fade); +} + +.cell[data-on='true'] { + background: linear-gradient(color-mix(in oklab, var(--fire-top) 50%, #ece6da), color-mix(in oklab, var(--fire-bottom) 50%, #dcd4c3)); +} + +/* The playhead: a darker key with a firmer ring, so it reads at phone size. */ +.cell[data-now='true'] { + background: linear-gradient(#d9d5cc, #c4bfb5); + box-shadow: 0 0 0 1px rgba(42, 44, 47, 0.7); + transition-duration: var(--flash); +} + +.cell[data-on='true'][data-now='true'] { + background: linear-gradient(var(--fire-top), var(--fire-bottom)); + box-shadow: + 0 0 0 1px rgba(42, 44, 47, 0.35), + 0 0 8px rgba(var(--fire-glow), 0.6); +} + +/* The one big key: START, orange while playing, like the panel's. */ +.play { + width: 100%; + max-width: 260px; + height: 64px; + border: 1px solid var(--hair); + border-radius: 14px; + background: var(--key-face); + box-shadow: + 0 1px 2px rgba(31, 33, 36, 0.1), + inset 0 -2px 0 rgba(31, 33, 36, 0.06); + font: 800 15px/1 var(--font-label); + font-stretch: 108%; + letter-spacing: 0.12em; + color: var(--label); +} + +.play[data-playing='true'] { + background: linear-gradient(#f2a587, #d9744f); + color: #fff8ef; +} + +.play:active { + transform: translateY(1px); +} + +.play:focus-visible { + outline: 2px solid var(--navy); + outline-offset: 3px; +} + +.hint { + margin: 0; + font: 500 12px/1.4 var(--font-label); + color: var(--muted); + text-align: center; +} diff --git a/src/ui/Player.test.tsx b/src/ui/Player.test.tsx new file mode 100644 index 0000000..dd21231 --- /dev/null +++ b/src/ui/Player.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { STOPPED } from '../engine/engine' +import { PRESETS } from '../session/library' +import { defaultSession } from '../session/schema' +import { createSessionStore } from '../session/store' +import { decodeSession } from '../session/url' +import { Player } from './Player' + +const renderPlayer = (link: string | null, isPlaying = false) => { + const store = createSessionStore(link ? decodeSession(link) : defaultSession()) + const onStartStop = vi.fn() + render() + return { store, onStartStop } +} + +describe('Player', () => { + it('plays and switches loops, with nothing to edit', async () => { + const user = userEvent.setup() + const { onStartStop } = renderPlayer(PRESETS[0].link) + expect(screen.getByText(PRESETS[0].name)).toBeInTheDocument() + expect(screen.queryAllByRole('slider')).toHaveLength(0) + expect(screen.queryByRole('button', { name: /step/ })).toBeNull() + + await user.click(screen.getByRole('button', { name: 'START' })) + expect(onStartStop).toHaveBeenCalledOnce() + + await user.click(screen.getByRole('radio', { name: PRESETS[4].name })) + expect(screen.getByText(PRESETS[4].name)).toBeInTheDocument() + }) + + it('names a loop that is not one of the six a shared loop', () => { + renderPlayer('8888080880a2_000500') + expect(screen.getByText('Shared loop')).toBeInTheDocument() + expect(screen.getAllByRole('radio').every((r) => r.getAttribute('aria-checked') === 'false')).toBe(true) + }) + + it('shows STOP while playing', () => { + renderPlayer(PRESETS[0].link, true) + expect(screen.getByRole('button', { name: 'STOP' })).toBeInTheDocument() + }) +}) diff --git a/src/ui/Player.tsx b/src/ui/Player.tsx new file mode 100644 index 0000000..94e6ca6 --- /dev/null +++ b/src/ui/Player.tsx @@ -0,0 +1,84 @@ +import { DRUMS, SYNTH_FIRE } from '../instruments' +import type { Playhead } from '../engine/engine' +import { useSession, type SessionStore } from '../session/store' +import { decodeSession } from '../session/url' +import { LedChoice } from './controls/LedButton' +import { fireVars } from './fire' +import { Nameplate } from './Nameplate' +import { PATTERN_OPTIONS, presetFor } from './patterns' +import housing from './Housing.module.css' +import styles from './Player.module.css' +import transport from './Transport.module.css' + +interface PlayerProps { + store: SessionStore + playhead: Playhead + isPlaying: boolean + onStartStop: () => void +} + +/** Four beats of four steps, like the panel's beat groups. */ +const BEATS = [0, 4, 8, 12].map((start) => [start, start + 1, start + 2, start + 3]) + +/** + * The phone layout: a listening player, not an editor. It plays the loop a + * link opened or one of the ready-made loops, and shows the pattern running. + */ +export function Player({ store, playhead, isPlaying, onStartStop }: PlayerProps) { + const session = useSession(store, (s) => s) + const preset = presetFor(session) + const rows = [ + ...DRUMS.map((d) => ({ id: d.id, label: d.label.toUpperCase(), steps: session.drums[d.id].steps, fire: d.fire })), + { id: 'synth', label: 'SYNTH', steps: session.synth.steps, fire: SYNTH_FIRE }, + ] + + return ( +
+
+ + +
+ {preset?.name ?? 'Shared loop'} +
+ + 888 + + + {String(session.bpm).padStart(3, '0')} + +
+
+ +
+ {rows.map((row) => ( +
+ {row.label} +
+ {BEATS.map((beat) => ( +
+ {beat.map((i) => ( + + ))} +
+ ))} +
+
+ ))} +
+ + + + store.dispatch({ type: 'loadSession', session: decodeSession(link) })} + /> + +

Open on a computer to make your own loop.

+
+
+ ) +} diff --git a/src/ui/fire.ts b/src/ui/fire.ts new file mode 100644 index 0000000..a005565 --- /dev/null +++ b/src/ui/fire.ts @@ -0,0 +1,5 @@ +import type { CSSProperties } from 'react' +import type { FireColor } from '../instruments' + +/** A row's firing color as the CSS variables its step keys read. */ +export const fireVars = (c: FireColor) => ({ '--fire-top': c.top, '--fire-bottom': c.bottom, '--fire-glow': c.glow }) as CSSProperties diff --git a/src/ui/patterns.ts b/src/ui/patterns.ts new file mode 100644 index 0000000..89f67cc --- /dev/null +++ b/src/ui/patterns.ts @@ -0,0 +1,12 @@ +import { PRESETS } from '../session/library' +import type { Session } from '../session/schema' +import { encodeSession } from '../session/url' + +/** The ready-made loops as LED keys numbered 1–6. */ +export const PATTERN_OPTIONS = PRESETS.map((p, i) => ({ value: p.link, content: String(i + 1), name: p.name })) + +/** The ready-made loop the session exactly matches, if any; changing anything makes it your own. */ +export const presetFor = (session: Session) => { + const link = encodeSession(session) + return PRESETS.find((p) => p.link === link) +} diff --git a/src/ui/tokens.css b/src/ui/tokens.css index e59701b..4212e1d 100644 --- a/src/ui/tokens.css +++ b/src/ui/tokens.css @@ -78,6 +78,13 @@ body { #e9e5dc; } +/* Phones show the compact player; give it most of the width. */ +@media (max-width: 700px) { + body { + padding: 16px 12px; + } +} + button { font: inherit; cursor: pointer; diff --git a/src/ui/useMediaQuery.ts b/src/ui/useMediaQuery.ts new file mode 100644 index 0000000..9f0128f --- /dev/null +++ b/src/ui/useMediaQuery.ts @@ -0,0 +1,13 @@ +import { useSyncExternalStore } from 'react' + +/** Whether a CSS media query matches, kept current as the window changes. */ +export function useMediaQuery(query: string): boolean { + return useSyncExternalStore( + (onChange) => { + const list = window.matchMedia(query) + list.addEventListener('change', onChange) + return () => list.removeEventListener('change', onChange) + }, + () => window.matchMedia(query).matches, + ) +}