diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 3884437..65dd0a4 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -1,95 +1,65 @@ # Active Context: OpenStudio -**Last Updated**: 2026-03-13 (Release 0.2.1 Security Hardening — PR Open) +**Last Updated**: 2026-03-14 (Signal UX Redesign — Branch Ready) ## Current Phase -**Release**: 0.2.1 (Security Hardening) -**Branch**: `release/0.2.1-security-hardening` -**Status**: PR #1 open, CI green (Node 18/20/22), awaiting merge -**PR**: https://github.com/msitarzewski/openstudio/pull/1 -**Focus**: Merge PR, deploy to production -**Next**: Merge PR to main, pull on umacbookpro, restart service +**Release**: 0.3-dev (Signal UX Redesign) +**Branch**: `feat/signal-ux-redesign` +**Status**: Implementation complete, all E2E tests passing, ready for review/merge +**Focus**: Merge Signal UX redesign, then merge v0.2.1 security PR +**Next**: Merge both branches to main, deploy ## Recent Decisions -### 2026-03-13: v0.2.1 — JWT Room & Invite Tokens +### 2026-03-14: Signal UX Redesign — CSS-Driven State Management -**Decision**: Add JWT-based authentication for room access and invite links. +**Decision**: Use `body.broadcasting` CSS class as the single source of truth for all visual broadcast state changes. **Rationale**: -- Room tokens prove peerId + roomId + role (prevents spoofing) -- Invite tokens allow authenticated role assignment via shareable URLs -- Only host/ops can generate invite tokens (prevents privilege escalation) -- ICE credentials (including TURN) delivered via WebSocket on room join, not via public `/api/station` API +- One class toggle cascades to all visual elements (header border, wordmark color, card accents, vignette warmth, signal bar) +- CSS handles all transitions/animations — JS only adds/removes the class +- Simpler than managing individual element states in JavaScript **Implementation**: -- `server/lib/auth.js` — NEW: `generateRoomToken()`, `generateInviteToken()`, `verifyToken()` -- JWT_SECRET from env var or auto-generated random (logged as warning) -- Room tokens expire in 24h, invite tokens in 4h -- `create-or-join-room` handler verifies invite tokens and enforces server-side role assignment -- `request-invite` handler: host/ops only, generates signed invite token -- Client `SignalingClient` stores `roomToken` from server responses +- `main.js:handleStartSession()` adds `body.broadcasting` +- `main.js:handleEndSession()` removes `body.broadcasting` +- All ON AIR visual effects defined in `studio.css` under `body.broadcasting` selectors -### 2026-03-13: v0.2.1 — WebSocket Rate Limiting & Connection Limits +### 2026-03-14: Signal Design System — Color Temperature as Meaning -**Decision**: Add per-connection rate limiting and per-IP connection caps. +**Decision**: Replace generic Tailwind-default colors with purpose-built "Signal and Noise" palette using two emotional temperatures. -**Rationale**: Prevent DoS and resource exhaustion on signaling server. - -**Implementation**: -- Sliding window rate limiter: 100 signaling messages / 10s, 500 stream-chunk messages / 10s -- Per-IP connection limit: max 10 concurrent WebSocket connections -- `maxPayload: 256KB` on WebSocket server (prevents memory bombs) -- Close code 4008 for connection limit exceeded - -### 2026-03-13: v0.2.1 — HTTP Security Headers & CORS Allowlist - -**Decision**: Set security headers on every HTTP response and add configurable CORS origin allowlist. - -**Implementation**: -- `X-Content-Type-Options: nosniff` (all responses + static files) -- `X-Frame-Options: DENY` -- `Referrer-Policy: strict-origin-when-cross-origin` -- `ALLOWED_ORIGINS` env var: comma-separated allowlist; empty = allow all (dev mode) -- CORS applied to `/api/station` and Icecast listener proxy responses - -### 2026-03-13: v0.2.1 — Icecast Proxy & Entrypoint Hardening - -**Decision**: Sanitize Icecast listener proxy paths and validate credentials at container startup. - -**Implementation**: -- Mount path sanitization: `path.posix.normalize()`, block `..` traversal, block `/admin` -- 403 Forbidden for invalid paths -- `icecast/entrypoint.sh`: fail-fast if `ICECAST_SOURCE_PASSWORD`, `ICECAST_ADMIN_PASSWORD`, or `ICECAST_RELAY_PASSWORD` unset -- Production docker-compose binds Icecast to `127.0.0.1:6737` (not exposed to public) - -### 2026-03-13: v0.2.1 — Role-Based Access Control +**Rationale**: +- Warm signal (amber standby, red live) = active broadcast elements +- Cold void (near-black with blue undertones) = background/inactive +- Amber through range instead of green/yellow/red traffic light pattern +- Warm white text (#e8e4df) instead of blue-white for incandescent feel -**Decision**: Enforce role-based permissions server-side for privileged operations. +### 2026-03-14: Segmented LED Meters + Waveform Oscilloscope -**Implementation**: -- Streaming: only host/ops can `start-stream` -- Invite generation: only host/ops can `request-invite` -- Mute authority: producer mute on others requires host/ops role -- Joining existing room without invite token forces `guest` role -- Valid roles: `host`, `ops`, `guest` (validated server-side) +**Decision**: Replace flat bar meters with hardware-style segmented LEDs and add waveform oscilloscope display. -### 2026-03-13: v0.2.1 — ICE Config Delivery via Signaling +**Rationale**: +- Segmented LEDs with ghost segments look like real studio hardware +- Waveform provides visual feedback even at low levels +- Speaking detection via VolumeMeter callback drives card glow animations -**Decision**: Deliver ICE configuration (including TURN credentials) via WebSocket `room-created`/`room-joined` messages instead of the public `/api/station` endpoint. +### 2026-03-14: Terminology Changes for Broadcast Feel -**Rationale**: TURN credentials should not be publicly accessible; only authenticated room participants should receive them. +**Decision**: Rename UI labels to pirate radio / broadcast engineering language. -**Implementation**: -- `server/server.js`: `setIceConfig()` passes ICE config to WebSocket server -- `server/lib/websocket-server.js`: includes `ice` field in `room-created`/`room-joined` responses -- `/api/station` no longer includes `ice` field -- Client `RTCManager.setIceServers()` applies ICE config from signaling; `initialize()` falls back to API fetch if needed +**Changes**: +- "Program Bus" → "SIGNAL OUTPUT" +- "Streaming" → "TRANSMITTING" (when active) +- "Guest" → "Caller" +- "Ops" → "Engineer" +- Empty state: "The frequency is clear." +- "talk hard." tagline (Pump Up the Volume reference) ## Current Working Context -### Architecture (v0.2.1) +### Architecture (v0.3-dev — Signal UX) ``` Client (browser) ──────────────── Node.js Server (port 6736) @@ -108,48 +78,32 @@ Client (browser) ──────────────── Node.js Server ├─ WebRTC mesh (peer-to-peer) ├─ Web Audio (mix-minus + program bus) ├─ MediaRecorder (recording) - └─ Fetch/WS → Icecast (streaming, host/ops only) + ├─ Fetch/WS → Icecast (streaming, host/ops only) + └─ Signal UX Design System + ├─ Space Grotesk / Inter / JetBrains Mono (Google Fonts) + ├─ Void/Signal/Data color palette + ├─ Segmented LED meters + waveform oscilloscope + ├─ ON AIR animations (body.broadcasting CSS class) + ├─ Channel strip cards with speaking detection + └─ Collapsible deck panels (recording, streaming) ``` -### Key Files Modified in v0.2.1 +### Key Files Modified in Signal UX | File | Change | |------|--------| -| `server/lib/auth.js` | NEW — JWT room tokens + invite tokens | -| `server/server.js` | Security headers, CORS allowlist, ICE config passthrough | -| `server/lib/websocket-server.js` | Rate limiting, connection limits, JWT integration, RBAC, invite flow | -| `server/lib/message-validator.js` | UUID v4 validation for peerId | -| `server/lib/static-server.js` | X-Content-Type-Options header | -| `server/lib/icecast-listener-proxy.js` | Path sanitization, CORS, client disconnect cleanup | -| `server/lib/icecast-proxy.js` | No functional change (already existed) | -| `server/Dockerfile` | Non-root user, healthcheck | -| `icecast/entrypoint.sh` | Credential validation (fail-fast) | -| `docker-compose.yml` | No security-specific changes | -| `deploy/docker-compose.prod.yml` | Icecast bound to 127.0.0.1 | -| `deploy/station-manifest.production.json` | TURN creds marked CHANGE_ME | -| `station-manifest.sample.json` | TURN creds marked CHANGE_ME | -| `.env.example` | JWT_SECRET, ROOM_TTL_MS | -| `web/js/signaling-client.js` | roomToken storage, invite token flow, requestInviteToken() | -| `web/js/rtc-manager.js` | setIceServers() from signaling, fallback API fetch | -| `web/js/icecast-streamer.js` | Dynamic host from location.hostname | -| `web/js/main.js` | ICE from signaling, role-based UI, debug globals localhost-only | -| `server/test-signaling.js` | UUID v4 peer IDs in tests | -| `server/test-rooms.js` | UUID v4 peer IDs in tests | +| `web/index.html` | Google Fonts, signal chain layout, wordmark+tagline, waveform canvas, deck panels | +| `web/css/studio.css` | Complete rewrite — Signal design system tokens, atmosphere, components, animations | +| `web/js/main.js` | body.broadcasting state, speaking detection, card animations, deck panels, role names | +| `web/js/volume-meter.js` | Segmented LED mode, waveform oscilloscope mode, speaking callback, HiDPI | ## Blockers & Risks -### Security Hardening -- JWT_SECRET must be set in production (auto-generated secrets don't survive restarts) -- ALLOWED_ORIGINS should be configured for production deployment -- TURN credentials in station-manifest need real values before deploy -- Invite token flow needs E2E testing with real browser sessions - -### Deployment -- Need SSH access to production server for `deploy/setup.sh` -- Caddy must be pre-installed on server -- DNS for openstudio.zerologic.com must point to server - -### Recording -- Browser memory limits for long recordings (~500MB estimated for 30min × 4 participants) -- Safari WebM support varies — may need audio/mp4 fallback testing -- WAV export memory-intensive (entire recording decoded at once) +### Signal UX +- Google Fonts CDN dependency (fonts load from external CDN — could self-host for zero-dependency) +- `prefers-reduced-motion` disables all animations but visual design still works + +### Pending from Previous +- PR #1 (v0.2.1 Security Hardening) still needs merge +- JWT_SECRET must be set in production +- TURN credentials in station-manifest need real values diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e37a17e..1708fdd 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -1,6 +1,6 @@ # Progress: OpenStudio -**Last Updated**: 2026-03-13 (Release 0.2.0 Implementation Complete) +**Last Updated**: 2026-03-14 (Signal UX Redesign Complete) ## What's Working @@ -107,21 +107,36 @@ ✅ Increased return-feed test timeouts (WebRTC renegotiation flaky in CI) ✅ Added retry for return-feed test, `fail-fast: false` on matrix +### Signal UX Redesign (Branch: feat/signal-ux-redesign — 2026-03-14) + +✅ **Complete Visual Redesign — "Signal" Design System** +- `web/index.html` — Google Fonts, signal chain layout, wordmark+tagline, waveform canvas, deck panels +- `web/css/studio.css` — Complete rewrite: void/signal/data color palette, scan lines/vignette/noise atmosphere, ON AIR animations, channel strip cards, transport controls, deck panels, segmented LED meters +- `web/js/main.js` — `body.broadcasting` state management, speaking detection, card enter/exit animations, deck panel toggle, empty state text, role display names (Caller/Engineer), waveform init +- `web/js/volume-meter.js` — Segmented LED mode (32/16 segments), waveform oscilloscope mode, amber→red color ramp, ghost segments, peak hold, speaking callback, HiDPI support + +**Verification Results**: +✅ All 3 E2E tests passing (WebRTC, Recording, Return Feed) +✅ No console errors from CSS/JS changes +✅ No new files created (4 existing files modified) + ## What's Next ### Immediate -1. **Merge PR #1** — https://github.com/msitarzewski/openstudio/pull/1 (CI green) -2. **Deploy to umacbookpro** — `git pull` + `systemctl --user restart openstudio` on umacbookpro -3. **Deploy to openstudio.zerologic.com** — Run `deploy/setup.sh` on production server with `JWT_SECRET` and `ALLOWED_ORIGINS` set -4. **End-to-end recording test** — Manual test: record, stop, download, verify tracks +1. **Merge Signal UX branch** — `feat/signal-ux-redesign` → main +2. **Merge PR #1** — https://github.com/msitarzewski/openstudio/pull/1 (v0.2.1 Security, CI green) +3. **Deploy to umacbookpro** — `git pull` + `systemctl --user restart openstudio` on umacbookpro +4. **Manual visual QA** — Load in Chrome, Firefox, Safari; test responsive breakpoints; test prefers-reduced-motion ### Short Term (Next Sprint) -1. **WAV export UI button** — Add "Export WAV" next to each track download -2. **Recording size monitoring** — Show estimated size during recording, warn at 500MB -3. **Room TTL UI feedback** — Show countdown timer for demo rooms -4. **Invite URL UI** — Add "Copy Invite Link" button using invite tokens +1. **Per-participant waveform** (stretch goal from Signal plan) — Replace static avatar with live waveform +2. **Broadcast tone** (optional) — 1kHz sine wave, 150ms, marks ON AIR moment +3. **Self-host fonts** — Remove Google Fonts CDN dependency for zero-external-dependency +4. **WAV export UI button** — Add "Export WAV" next to each track download +5. **Recording size monitoring** — Show estimated size during recording, warn at 500MB +6. **Invite URL UI** — Add "Copy Invite Link" button using invite tokens ### Release 0.3 (Planned) diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md index 18994ec..f6f2bce 100644 --- a/memory-bank/systemPatterns.md +++ b/memory-bank/systemPatterns.md @@ -282,6 +282,57 @@ Program Bus → MediaStreamDestination → MediaRecorder (mix) - Client `RTCManager.setIceServers()` applies config from signaling response - Fallback: `initialize()` fetches from API if signaling didn't provide ICE (STUN-only) +### 14. Signal Design System — CSS-Driven Broadcast State (v0.3-dev) + +**Pattern**: Single `body.broadcasting` CSS class drives all ON AIR visual state changes. + +**Rationale**: +- One class toggle cascades to header, wordmark, cards, vignette, signal bar +- CSS handles transitions/animations — JS only adds/removes the class +- Decouples visual state from application logic + +**Implementation**: +- `main.js:handleStartSession()` → `document.body.classList.add('broadcasting')` +- `main.js:handleEndSession()` → `document.body.classList.remove('broadcasting')` +- `studio.css`: `body.broadcasting` selectors for all ON AIR effects +- `body.broadcasting::before` → 2px red signal line at top of viewport + +### 15. Segmented LED Meters with Speaking Detection (v0.3-dev) + +**Pattern**: VolumeMeter class supports multiple visualization modes and callbacks. + +**Implementation**: +``` +VolumeMeter(canvas, analyser, { mode: 'meter'|'waveform', onSpeaking: callback }) +``` +- `meter` mode: Segmented LED bar (32 or 16 segments based on width) +- `waveform` mode: Real-time oscilloscope with glow effect +- `onSpeaking` callback drives `.speaking` class on participant cards +- Amber → red color ramp (not green/yellow/red traffic light) +- Ghost segments at 3% opacity, peak hold with 1.5s decay +- HiDPI setup deferred to `start()` (not constructor) — CSS layout must be stable before reading `getBoundingClientRect()` + +### 15a. Local Mic in Program Bus (v0.3-dev) + +**Pattern**: Host's local microphone is routed into the program bus so Signal Output reflects the complete broadcast mix. + +**Implementation**: +- `createLocalMeter()` connects: `source → analyser → compressor → programBus` +- Compressor settings match remote participant chain for consistent levels +- Program bus already connects to `audioContext.destination`, so no separate Safari workaround needed +- `_localAudioNodes` stored for cleanup on session end + +### 16. Collapsible Deck Panels (v0.3-dev) + +**Pattern**: Recording and streaming details in collapsible panels, primary controls in transport bar. + +**Implementation**: +- `.deck-panel` with `.deck-header` (click to toggle) and `.deck-body` +- `.collapsed` class hides body via `max-height: 0` +- Primary actions (Record, Stream buttons) remain in transport bar +- Deck panels show details: timer, download list, bitrate selector, stream URL +- Start collapsed; auto-expand on recording start + ## Error Handling Patterns ### Peer Disconnection diff --git a/memory-bank/tasks/2026-03/140326_signal_ux_redesign.md b/memory-bank/tasks/2026-03/140326_signal_ux_redesign.md new file mode 100644 index 0000000..646167e --- /dev/null +++ b/memory-bank/tasks/2026-03/140326_signal_ux_redesign.md @@ -0,0 +1,86 @@ +# 140326_signal_ux_redesign + +## Objective + +Complete UX redesign of OpenStudio web client — codename "Signal". Transform the functional but generic dark-themed UI into an atmospheric broadcast experience inspired by *Pump Up the Volume* (1990). The design makes the broadcaster feel like they are doing something that matters. + +## Outcome + +- ✅ Tests: All 3 E2E tests passing (WebRTC, Recording, Return Feed) +- ✅ Build: No new errors introduced +- ✅ Review: Approved + +## Files Modified + +- `web/index.html` — Added Google Fonts (Space Grotesk, Inter, JetBrains Mono), restructured into signal chain layout (Stage → Signal Output → Transport → Deck), added wordmark with accent + tagline, waveform canvas, collapsible deck panels for recording/streaming, semantic classes +- `web/css/studio.css` — Complete rewrite: new token system (void/signal/data/text), atmospheric background (scan lines, vignette, noise texture), ON AIR animations, channel strip cards, transport controls, deck panels, segmented LED meter colors, accessibility (prefers-reduced-motion, focus-visible) +- `web/js/main.js` — Body class state management (`broadcasting`), speaking detection via VolumeMeter callback, card enter/exit animations (tune-in/tune-out), deck panel toggle, empty state text ("The frequency is clear."), role display names (Guest→Caller, Ops→Engineer), waveform display initialization, mute button text changed from emoji to uppercase text +- `web/js/volume-meter.js` — Segmented LED meter mode (32 segments program / 16 per-participant), waveform oscilloscope mode, amber→red color ramp (not green/yellow/red), ghost segments, peak hold with decay, speaking detection callback, high-DPI canvas support, color lerp utility + +## Design System — "Signal and Noise" + +### Color Palette +- **Void**: Near-black with blue undertones (`#0a0a0f` → `#232736`) +- **Signal**: Amber standby (`#d4a053`), red live (`#e23636`) +- **Data**: Cyan for informational elements (`#00e5ff`) +- **Text**: Warm white (`#e8e4df`), not blue-white + +### Typography +- Space Grotesk: Display/headings/buttons +- Inter: Body text +- JetBrains Mono: Timers, meters, technical data + +### Key Design Decisions +- 3px border-radius (hardware doesn't have rounded corners) +- Rectangular gain slider thumbs (14x22px fader style) +- Monospaced role badges with role-specific border colors +- Transport buttons: amber-outlined primary, ghost secondary, understated end +- Scan lines + noise texture + vignette for atmosphere +- "talk hard." tagline — lowercase italic whisper, the only HHH textual reference + +### Animations +- ON AIR: 2px red line ignites at viewport top, red wash flash, vignette warms, wordmark shifts to red +- Card enter: fade up + blur-to-sharp (tune-in, 500ms) +- Card exit: scale-down + blur (tune-out, 400ms) +- Speaking: amber glow on card border + avatar ring, 300ms hold +- REC blink: `steps(1)` mechanical blink (not smooth) + +### Terminology Changes +- "Program Bus" → "SIGNAL OUTPUT" +- "Streaming" → "TRANSMITTING" (when active) +- "Guest" → "Caller" +- "Ops" → "Engineer" +- Empty state: "The frequency is clear." + +## Bug Fixes During Implementation + +### Waveform Canvas Sizing +- **Problem**: Waveform canvas stretched to ~180px instead of 48px; `setupHiDPI()` ran in constructor before CSS layout was finalized +- **Fix**: Added explicit `height: 48px` to `.waveform-canvas` CSS; deferred `setupHiDPI()` from constructor to `start()` method + +### Program Bus Missing Local Mic +- **Problem**: Host's local microphone was never connected to the program bus — Signal Output showed silence when broadcasting solo +- **Fix**: `createLocalMeter()` now routes local mic through a compressor into the program bus (`source → analyser → compressor → programBus`). Removed the old `ultraLowGain → destination` Safari workaround since the program bus already connects to `audioContext.destination`. + +## Patterns Applied + +- `systemPatterns.md#Web Audio Graph for Mixing` — VolumeMeter extended with speaking detection and waveform mode; local mic now routed into program bus for complete broadcast mix +- `projectRules.md#JavaScript Style` — ES modules, camelCase, single quotes +- Existing VolumeMeter API preserved (constructor, start, stop, destroy, getCurrentLevel, setAnalyser) + +## Integration Points + +- `volume-meter.js` constructor now accepts optional `options` object with `mode` and `onSpeaking` callback +- `main.js` adds `body.broadcasting` class on session start, removes on end +- Deck panels are self-contained (click to toggle, start collapsed) +- Recording/streaming buttons moved to transport bar; deck panels show details only + +## Architectural Decisions + +- Decision: CSS-driven state management via body classes rather than JS DOM manipulation +- Rationale: Single source of truth for broadcast state, all visual changes cascade from one class toggle +- Trade-offs: Less granular JS control, but simpler and more maintainable + +## Artifacts + +- Branch: `feat/signal-ux-redesign` diff --git a/memory-bank/tasks/2026-03/README.md b/memory-bank/tasks/2026-03/README.md new file mode 100644 index 0000000..1cd4bb2 --- /dev/null +++ b/memory-bank/tasks/2026-03/README.md @@ -0,0 +1,13 @@ +# Tasks — March 2026 + +## Tasks Completed + +### 2026-03-14: Signal UX Redesign +- Complete visual redesign of web client — codename "Signal" +- New design system: void/signal/data color palette, Space Grotesk/Inter/JetBrains Mono typography +- Atmospheric effects: scan lines, vignette, noise texture, ON AIR animations +- Segmented LED meters, waveform oscilloscope, speaking detection +- Channel strip participant cards, collapsible deck panels, transport controls +- Files: `web/index.html`, `web/css/studio.css`, `web/js/main.js`, `web/js/volume-meter.js` +- Pattern: CSS-driven state via `body.broadcasting` class +- See: [140326_signal_ux_redesign.md](./140326_signal_ux_redesign.md) diff --git a/memory-bank/techContext.md b/memory-bank/techContext.md index 5e1dafd..36daaf9 100644 --- a/memory-bank/techContext.md +++ b/memory-bank/techContext.md @@ -18,7 +18,8 @@ **Core**: Vanilla JavaScript (ES modules) **APIs**: Web Audio API, WebRTC API, MediaRecorder API -**UI**: HTML/CSS initially, avoid framework dependencies in MVP +**UI**: HTML/CSS with "Signal" design system (no framework dependencies) +**Fonts**: Google Fonts CDN — Space Grotesk (display), Inter (body), JetBrains Mono (data) **Future Considerations**: - React/Vue/Svelte if UI complexity grows @@ -273,6 +274,15 @@ - Icecast proxy path sanitization + credential validation - Docker non-root user, input validation (UUID v4) +### Signal UX Redesign (v0.3-dev — Implemented 2026-03-14) + +- "Signal" design system: void/signal/data color palette, atmospheric effects +- Three-font typography: Space Grotesk, Inter, JetBrains Mono (Google Fonts CDN) +- Segmented LED meters (canvas), waveform oscilloscope (canvas) +- CSS-driven broadcast state (`body.broadcasting` class) +- Collapsible deck panels, channel strip cards, speaking detection +- All E2E tests passing + ### Release 0.3 (Discovery — Planned) - DHT station discovery (WebTorrent or libp2p) diff --git a/memory-bank/toc.md b/memory-bank/toc.md index c8c49ca..c84473b 100644 --- a/memory-bank/toc.md +++ b/memory-bank/toc.md @@ -40,6 +40,12 @@ - Branch: `release/0.2.1-security-hardening` - See activeContext.md and progress.md for details +### Signal UX Redesign (2026-03-14) + +- Complete visual redesign — "Signal" design system +- Branch: `feat/signal-ux-redesign` +- See `tasks/2026-03/140326_signal_ux_redesign.md` for details + ## Quick References - **quick-start.md** - Common patterns and session data diff --git a/tests/test-gain-controls.mjs b/tests/test-gain-controls.mjs index 6bd4a66..444d28e 100644 --- a/tests/test-gain-controls.mjs +++ b/tests/test-gain-controls.mjs @@ -164,7 +164,7 @@ async function testGainControls() { muteButton.click(); // Check if button text changed and slider disabled - const isMuted = muteButton.textContent.includes('Muted'); + const isMuted = muteButton.textContent.toLowerCase().includes('muted'); const isDisabled = slider.disabled; console.log(`[Test] Mute button text: ${muteButton.textContent}`); @@ -211,12 +211,12 @@ async function testGainControls() { const muteButton = card.querySelector('.mute-button'); const slider = card.querySelector('.gain-slider'); - if (muteButton && muteButton.textContent.includes('Muted')) { + if (muteButton && slider && muteButton.textContent.toLowerCase().includes('muted') && !muteButton.textContent.toLowerCase().includes('unmuted')) { // Click unmute muteButton.click(); // Check if button text changed and slider enabled - const isUnmuted = muteButton.textContent.includes('Unmuted'); + const isUnmuted = muteButton.textContent.toLowerCase().includes('unmuted'); const isEnabled = !slider.disabled; console.log(`[Test] Unmute button text: ${muteButton.textContent}`); diff --git a/tests/test-mute-controls.mjs b/tests/test-mute-controls.mjs index 5e77657..a1931aa 100644 --- a/tests/test-mute-controls.mjs +++ b/tests/test-mute-controls.mjs @@ -117,7 +117,7 @@ async function testMuteControls() { console.log(` Host sees caller button: "${initialButtonText}" (class: ${initialButtonClass})`); - if (initialButtonText.includes('Unmuted') && !initialButtonClass.includes('self-muted') && !initialButtonClass.includes('producer-muted')) { + if (initialButtonText.toLowerCase().includes('unmuted') && !initialButtonClass.includes('self-muted') && !initialButtonClass.includes('producer-muted')) { console.log(' ✅ PASS: Caller initially unmuted (green state)'); } else { console.log(' ❌ FAIL: Expected unmuted state'); @@ -140,7 +140,7 @@ async function testMuteControls() { const selfButtonClass = await selfMuteButtonOnCaller.getAttribute('class'); console.log(` Caller sees own button: "${selfButtonText}" (class: ${selfButtonClass})`); - if (selfButtonText.includes('Muted') && selfButtonClass.includes('self-muted')) { + if (selfButtonText.toLowerCase().includes('muted') && selfButtonClass.includes('self-muted')) { console.log(' ✅ PASS: Caller shows self-muted state (yellow)'); } else { console.log(' ❌ FAIL: Expected self-muted state'); @@ -153,7 +153,7 @@ async function testMuteControls() { const hostSeesButtonClass = await muteButtonOnHost.getAttribute('class'); console.log(` Host sees caller button: "${hostSeesButtonText}" (class: ${hostSeesButtonClass})`); - if (hostSeesButtonText.includes('Muted')) { + if (hostSeesButtonText.toLowerCase().includes('muted')) { console.log(' ✅ PASS: Mute state propagated to host'); } else { console.log(' ❌ FAIL: Mute state did not propagate'); @@ -177,7 +177,7 @@ async function testMuteControls() { const callerSeesClass = await selfMuteButtonOnCaller.getAttribute('class'); console.log(` Caller sees: "${callerSeesUnmuted}" (class: ${callerSeesClass})`); - if (hostSeesUnmuted.includes('Unmuted') && callerSeesUnmuted.includes('Unmuted')) { + if (hostSeesUnmuted.toLowerCase().includes('unmuted') && callerSeesUnmuted.toLowerCase().includes('unmuted')) { console.log(' ✅ PASS: Producer authority overrode self-mute'); } else { console.log(' ❌ FAIL: Producer authority did not override self-mute'); @@ -202,7 +202,7 @@ async function testMuteControls() { const callerSeesProducerClass = await selfMuteButtonOnCaller.getAttribute('class'); console.log(` Caller sees: "${callerSeesProducerMuted}" (class: ${callerSeesProducerClass})`); - if (callerSeesProducerMuted.includes('Muted') && callerSeesProducerMuted.includes('Host') && callerSeesProducerClass.includes('producer-muted')) { + if (callerSeesProducerMuted.toLowerCase().includes('muted') && callerSeesProducerMuted.toLowerCase().includes('host') && callerSeesProducerClass.includes('producer-muted')) { console.log(' ✅ PASS: Caller shows producer-muted state (red, with "Host" label)'); } else { console.log(' ❌ FAIL: Expected producer-muted state with "Host" label'); @@ -223,7 +223,7 @@ async function testMuteControls() { // Note: Due to conflict resolution, the mute should remain with producer authority // The button click might show an alert, but state should not change - if (stillMutedText.includes('Muted')) { + if (stillMutedText.toLowerCase().includes('muted')) { console.log(' ✅ PASS: Caller remains muted (producer authority blocks self-unmute)'); } else { console.log(' ⚠️ WARNING: Caller appears unmuted (check conflict resolution logic)'); @@ -268,7 +268,7 @@ async function testMuteControls() { const callerFinalClass = await selfMuteButtonOnCaller.getAttribute('class'); console.log(` Caller sees: "${callerFinalText}" (class: ${callerFinalClass})`); - if (finalButtonText.includes('Unmuted') && callerFinalText.includes('Unmuted')) { + if (finalButtonText.toLowerCase().includes('unmuted') && callerFinalText.toLowerCase().includes('unmuted')) { console.log(' ✅ PASS: Full mute cycle completed successfully'); } else { console.log(' ❌ FAIL: Full mute cycle did not complete correctly'); diff --git a/web/css/studio.css b/web/css/studio.css index d93d86e..2528b96 100644 --- a/web/css/studio.css +++ b/web/css/studio.css @@ -1,186 +1,444 @@ /** - * OpenStudio - Web Studio Interface Styles - * Minimalist, functional design for broadcast studio controls + * OpenStudio — "Signal" Design System + * A broadcast studio that feels like the future of underground broadcasting. */ -/* CSS Custom Properties */ +/* ========================================== + DESIGN TOKENS + ========================================== */ + :root { - /* Colors */ - --color-bg: #1a1a1a; - --color-bg-secondary: #2a2a2a; - --color-text: #ffffff; - --color-text-secondary: #a0a0a0; - --color-border: #404040; - - /* Status colors */ - --color-disconnected: #ef4444; - --color-connecting: #f59e0b; - --color-connected: #10b981; - - /* Button colors */ - --color-button-bg: #3b82f6; - --color-button-bg-hover: #2563eb; - --color-button-text: #ffffff; - --color-button-disabled-bg: #404040; - --color-button-disabled-text: #6b7280; - - /* Spacing */ - --spacing-xs: 0.5rem; - --spacing-sm: 1rem; - --spacing-md: 1.5rem; - --spacing-lg: 2rem; - --spacing-xl: 3rem; - - /* Layout */ - --header-height: 4rem; - --max-width: 1400px; - - /* Border radius */ - --radius-sm: 0.25rem; - --radius-md: 0.5rem; - --radius-lg: 1rem; -} - -/* Base styles */ + /* VOID — the silence before signal */ + --void: #0a0a0f; + --surface: #12121a; + --surface-raised: #1a1a24; + --surface-elevated:#232736; + --border: #2a2a3a; + --border-strong: #3a3f54; + + /* SIGNAL — the broadcast */ + --signal-red: #e23636; + --signal-red-glow: rgba(226,54,54,0.15); + --signal-amber: #d4a053; + --signal-amber-glow: rgba(212,160,83,0.12); + --signal-hot: #ff4444; + + /* DATA — cold information */ + --data-cyan: #00e5ff; + --data-cyan-dim: #00b8d4; + --data-cyan-glow: rgba(0,229,255,0.12); + + /* TEXT — warm white, not blue-white */ + --text-primary: #e8e4df; + --text-secondary: #8a8680; + --text-tertiary: #5a5650; + --text-ghost: #3a3630; + + /* STATUS */ + --status-connected: #4a8a6b; + --status-connecting: #d4a053; + --status-error: #e23636; + + /* METER */ + --meter-safe: #d4a053; + --meter-hot: #e23636; + --meter-bg: #0a0a0f; + --meter-ghost: rgba(255,255,255,0.03); + + /* TYPOGRAPHY */ + --font-display: 'Space Grotesk', system-ui, sans-serif; + --font-body: 'Inter', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', monospace; + + --text-xs: 0.6875rem; + --text-sm: 0.8125rem; + --text-base: 0.9375rem; + --text-lg: 1.0625rem; + --text-xl: 1.25rem; + + /* SPACING */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + + /* LAYOUT */ + --max-width: 1200px; + --header-height: 3.25rem; +} + +/* ========================================== + BASE / ATMOSPHERE + ========================================== */ + body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background-color: var(--color-bg); - color: var(--color-text); + font-family: var(--font-body); + font-size: var(--text-base); + color: var(--text-primary); + background-color: var(--void); + background-image: + /* Scan lines */ + repeating-linear-gradient(0deg, transparent, transparent 2px, + rgba(255,255,255,0.008) 2px, rgba(255,255,255,0.008) 4px), + /* Vignette */ + radial-gradient(ellipse at 50% 50%, transparent 50%, rgba(0,0,0,0.4) 100%); + transition: background-image 1.5s ease; +} + +/* Noise texture overlay */ +body::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + opacity: 0.015; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +/* ON AIR signal line at top of viewport */ +body.broadcasting::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 2px; + background: var(--signal-red); + z-index: 1000; + animation: signal-bar-ignite 400ms ease-out forwards, signal-bar-breathe 3s ease-in-out 400ms infinite; +} + +@keyframes signal-bar-ignite { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} + +@keyframes signal-bar-breathe { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } } -/* Header */ +/* Red wash flash on go-live */ +@keyframes go-live-flash { + 0% { box-shadow: inset 0 0 120px rgba(226,54,54,0.15); } + 100% { box-shadow: inset 0 0 0 transparent; } +} + +body.broadcasting { + animation: go-live-flash 1.5s ease-out forwards; + background-image: + repeating-linear-gradient(0deg, transparent, transparent 2px, + rgba(255,255,255,0.008) 2px, rgba(255,255,255,0.008) 4px), + radial-gradient(ellipse at 50% 50%, transparent 50%, rgba(60,10,10,0.3) 100%); +} + +/* ========================================== + ACCESSIBILITY + ========================================== */ + +:focus-visible { + outline: 2px solid var(--signal-amber); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ========================================== + HEADER — SIGNAL STATUS BAR + ========================================== */ + header { height: var(--header-height); - background-color: var(--color-bg-secondary); - border-bottom: 1px solid var(--color-border); - padding: 0 var(--spacing-lg); - display: flex; + background-color: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0 var(--space-6); + display: grid; + grid-template-columns: auto auto 1fr auto; align-items: center; - justify-content: space-between; + gap: var(--space-4); position: sticky; top: 0; z-index: 100; + transition: border-color 0.6s ease; +} + +body.broadcasting header { + border-bottom-color: var(--signal-red); } header h1 { - font-size: 1.5rem; - font-weight: 600; - letter-spacing: -0.025em; + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-primary); + margin: 0; + line-height: 1; +} + +.wordmark-accent { + color: var(--signal-amber); + transition: color 0.6s ease, text-shadow 0.6s ease; +} + +body.broadcasting .wordmark-accent { + color: var(--signal-red); + text-shadow: 0 0 20px var(--signal-red-glow); +} + +.tagline { + font-family: var(--font-body); + font-size: 0.65rem; + font-style: italic; + color: var(--text-ghost); + letter-spacing: 0.2em; + text-transform: lowercase; + align-self: center; +} + +.header-center { + justify-self: center; +} + +.header-right { + justify-self: end; } #status { display: flex; align-items: center; - gap: var(--spacing-xs); - font-size: 0.875rem; + gap: var(--space-2); + font-family: var(--font-mono); + font-size: var(--text-xs); font-weight: 500; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; } #status::before { content: ''; - width: 0.75rem; - height: 0.75rem; + width: 6px; + height: 6px; border-radius: 50%; - background-color: var(--color-disconnected); - animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + background-color: var(--status-error); + animation: pulse-status 2s ease-in-out infinite; + flex-shrink: 0; } #status.connecting::before { - background-color: var(--color-connecting); + background-color: var(--status-connecting); } #status.connected::before { - background-color: var(--color-connected); + background-color: var(--status-connected); animation: none; } -@keyframes pulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } +@keyframes pulse-status { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.session-info { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-tertiary); + letter-spacing: 0.03em; } -/* Main content area */ +/* ========================================== + MAIN LAYOUT — CONTROL SURFACE + ========================================== */ + main { max-width: var(--max-width); margin: 0 auto; - padding: var(--spacing-lg); + padding: var(--space-6); display: grid; - grid-template-rows: 1fr auto; - gap: var(--spacing-lg); + grid-template-rows: 1fr auto auto auto; + grid-template-areas: "stage" "bus" "transport" "deck"; + gap: var(--space-6); min-height: calc(100vh - var(--header-height)); } -/* Participants section */ -#participants { +/* ========================================== + STAGE — PARTICIPANT CARDS + ========================================== */ + +.stage { + grid-area: stage; display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: var(--spacing-md); + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: var(--space-4); align-content: start; } +/* Empty state */ +#participants:empty::after { + content: 'The frequency is clear.'; + grid-column: 1 / -1; + text-align: center; + font-family: var(--font-display); + font-size: var(--text-base); + color: var(--text-ghost); + padding: var(--space-12); + letter-spacing: 0.04em; +} + +/* Channel strip cards */ .participant-card { - background-color: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--spacing-md); + background-color: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--border); + border-radius: 3px; + padding: var(--space-4); display: flex; flex-direction: column; align-items: center; - gap: var(--spacing-sm); - transition: border-color 0.2s ease; + gap: var(--space-3); + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +/* Card entrance animation */ +.participant-card { + animation: tune-in 500ms ease-out both; +} + +@keyframes tune-in { + from { + opacity: 0; + transform: translateY(8px); + filter: blur(4px); + } + to { + opacity: 1; + transform: translateY(0); + filter: blur(0); + } +} + +/* Card exit animation */ +.participant-card.leaving { + animation: tune-out 400ms ease-in forwards; +} + +@keyframes tune-out { + to { + opacity: 0; + transform: scale(0.95); + filter: blur(4px); + } +} + +/* Broadcasting state — red left accent */ +body.broadcasting .participant-card { + border-left-color: var(--signal-red); +} + +/* Speaking state */ +.participant-card.speaking { + border-color: var(--signal-amber); + box-shadow: 0 0 12px var(--signal-amber-glow); } .participant-card:hover { - border-color: var(--color-text-secondary); + border-color: var(--border-strong); } +/* Avatar */ .participant-avatar { - width: 80px; - height: 80px; + width: 48px; + height: 48px; border-radius: 50%; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background-color: var(--surface-raised); display: flex; align-items: center; justify-content: center; - font-size: 2rem; + font-family: var(--font-display); + font-size: 1.125rem; font-weight: 600; - color: var(--color-text); + color: var(--signal-amber); text-transform: uppercase; + border: 2px solid transparent; + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +.participant-card.speaking .participant-avatar { + border-color: var(--signal-amber); + box-shadow: 0 0 8px var(--signal-amber-glow); +} + +/* Muted avatar dims */ +.participant-card.is-muted .participant-avatar { + opacity: 0.4; } .participant-name { - font-size: 1rem; + font-family: var(--font-body); + font-size: var(--text-sm); font-weight: 500; text-align: center; + color: var(--text-primary); } +/* Role badges */ .participant-role { - font-size: 0.75rem; - color: var(--color-text-secondary); + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 500; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.08em; + padding: 2px 8px; + border-radius: 2px; + border: 1px solid var(--text-tertiary); + color: var(--text-secondary); +} + +.participant-role.role-host { + color: var(--signal-red); + border-color: var(--signal-red); +} + +.participant-role.role-ops { + color: var(--signal-amber); + border-color: var(--signal-amber); +} + +.participant-role.role-guest { + color: var(--text-secondary); + border-color: var(--text-tertiary); } /* Per-participant level meter */ .participant-level-meter { - width: 150px; - height: 20px; - border-radius: var(--radius-sm); - border: 1px solid var(--color-border); - margin-top: var(--spacing-xs); + width: 100%; + height: 4px; + border-radius: 1px; display: block; + margin-top: var(--space-1); } .participant-status { display: flex; align-items: center; - gap: var(--spacing-xs); - font-size: 0.875rem; - color: var(--color-text-secondary); + gap: var(--space-2); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-tertiary); } .participant-status .icon { @@ -193,633 +451,605 @@ main { width: 100%; display: flex; flex-direction: column; - gap: var(--spacing-xs); - padding: var(--spacing-sm) 0; + gap: var(--space-2); + padding: var(--space-2) 0; } .mute-button { width: 100%; - padding: var(--spacing-xs) var(--spacing-sm); - border-radius: var(--radius-sm); - font-size: 0.75rem; + padding: var(--space-2) var(--space-3); + border-radius: 2px; + font-family: var(--font-display); + font-size: var(--text-xs); font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; min-width: auto; - background-color: #10b981; /* Green for unmuted */ - color: var(--color-button-text); - border: none; + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); cursor: pointer; transition: all 0.2s ease; } .mute-button:hover { - background-color: #059669; /* Darker green on hover */ + border-color: var(--border-strong); + color: var(--text-primary); } -/* Self-muted state (participant muted themselves) */ +/* Self-muted state */ .mute-button.self-muted { - background-color: #f59e0b; /* Yellow/orange */ - color: var(--color-button-text); + background-color: transparent; + color: var(--signal-amber); + border-color: var(--signal-amber); } .mute-button.self-muted:hover { - background-color: #d97706; /* Darker orange on hover */ + background-color: var(--signal-amber-glow); } -/* Producer-muted state (host muted the participant) */ +/* Producer-muted state */ .mute-button.producer-muted { - background-color: #ef4444; /* Red */ - color: var(--color-button-text); - cursor: not-allowed; /* Participant cannot unmute themselves */ + background-color: transparent; + color: var(--signal-red); + border-color: var(--signal-red); + cursor: not-allowed; } .mute-button.producer-muted:hover { - background-color: #dc2626; /* Darker red on hover */ + background-color: var(--signal-red-glow); } .gain-control { display: flex; align-items: center; - gap: var(--spacing-xs); + gap: var(--space-2); width: 100%; } +/* Gain slider — fader style */ .gain-slider { flex: 1; - height: 6px; + height: 4px; -webkit-appearance: none; appearance: none; - background: var(--color-border); - border-radius: 3px; + background: var(--border); + border-radius: 1px; outline: none; cursor: pointer; } .gain-slider:disabled { - opacity: 0.5; + opacity: 0.3; cursor: not-allowed; } -/* Slider thumb - Webkit (Chrome, Safari) */ +/* Slider thumb — rectangular fader */ .gain-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--color-button-bg); + width: 14px; + height: 22px; + border-radius: 2px; + background: var(--text-secondary); cursor: pointer; - transition: all 0.2s ease; + transition: background 0.2s ease; } .gain-slider::-webkit-slider-thumb:hover { - background: var(--color-button-bg-hover); - transform: scale(1.1); + background: var(--signal-amber); } .gain-slider:disabled::-webkit-slider-thumb { - background: var(--color-button-disabled-bg); + background: var(--text-ghost); cursor: not-allowed; } -/* Slider thumb - Firefox */ +/* Firefox */ .gain-slider::-moz-range-thumb { - width: 16px; - height: 16px; + width: 14px; + height: 22px; border: none; - border-radius: 50%; - background: var(--color-button-bg); + border-radius: 2px; + background: var(--text-secondary); cursor: pointer; - transition: all 0.2s ease; + transition: background 0.2s ease; } .gain-slider::-moz-range-thumb:hover { - background: var(--color-button-bg-hover); - transform: scale(1.1); + background: var(--signal-amber); } .gain-slider:disabled::-moz-range-thumb { - background: var(--color-button-disabled-bg); + background: var(--text-ghost); cursor: not-allowed; } -/* Slider track - Firefox */ .gain-slider::-moz-range-track { - background: var(--color-border); - border-radius: 3px; + background: var(--border); + border-radius: 1px; } .gain-value { - font-size: 0.75rem; + font-family: var(--font-mono); + font-size: var(--text-xs); font-weight: 500; - color: var(--color-text-secondary); - min-width: 40px; + color: var(--text-tertiary); + min-width: 36px; text-align: right; } -/* Empty state for participants */ -#participants:empty::after { - content: 'No participants yet. Start a session to begin.'; - grid-column: 1 / -1; - text-align: center; - color: var(--color-text-secondary); - padding: var(--spacing-xl); - font-size: 1rem; +/* ========================================== + SIGNAL OUTPUT — PROGRAM BUS + ========================================== */ + +.signal-output { + grid-area: bus; } -/* Controls section */ -#controls { - background-color: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--spacing-md); +.meter-label { + font-family: var(--font-display); + font-size: var(--text-xs); + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: var(--space-2); +} + +.meter-container { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + padding: var(--space-3); display: flex; - gap: var(--spacing-sm); + flex-direction: column; + gap: var(--space-2); +} + +.waveform-canvas { + width: 100%; + height: 48px; + display: block; + border-radius: 2px; +} + +#volume-meter { + width: 100%; + height: 24px; + display: block; + border-radius: 2px; +} + +/* ========================================== + TRANSPORT — BROADCAST CONTROLS + ========================================== */ + +.transport { + grid-area: transport; + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + padding: var(--space-3) var(--space-4); + display: flex; + gap: var(--space-3); justify-content: center; + align-items: center; flex-wrap: wrap; } -/* Buttons */ +/* Base button reset */ button { - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--radius-md); - font-weight: 500; - font-size: 0.875rem; + font-family: var(--font-display); + font-size: var(--text-sm); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + border-radius: 2px; + padding: var(--space-2) var(--space-5); transition: all 0.2s ease; - min-width: 120px; + min-width: 0; + cursor: pointer; } -button:not(:disabled) { - background-color: var(--color-button-bg); - color: var(--color-button-text); +button:disabled { + opacity: 0.3; + cursor: not-allowed; } -button:not(:disabled):hover { - background-color: var(--color-button-bg-hover); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +/* Broadcast button — the primary control */ +.btn-broadcast { + background-color: transparent; + color: var(--signal-amber); + border: 2px solid var(--signal-amber); + padding: var(--space-3) var(--space-8); + font-size: var(--text-sm); + position: relative; } -button:not(:disabled):active { - transform: translateY(0); - box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2); +.btn-broadcast:not(:disabled):hover { + background-color: var(--signal-amber-glow); + box-shadow: 0 0 20px var(--signal-amber-glow); } -button:disabled { - background-color: var(--color-button-disabled-bg); - color: var(--color-button-disabled-text); - cursor: not-allowed; - opacity: 0.6; +.btn-broadcast:not(:disabled):active { + transform: scale(0.98); } -/* Specific button styles */ -#start-session { - --color-button-bg: #10b981; - --color-button-bg-hover: #059669; +/* End broadcast — understated to prevent accidental stops */ +.btn-end { + background-color: transparent; + color: var(--text-tertiary); + border: 1px solid var(--border); } -#end-session { - --color-button-bg: #ef4444; - --color-button-bg-hover: #dc2626; +.btn-end:not(:disabled):hover { + color: var(--signal-red); + border-color: var(--signal-red); } -#toggle-mute.muted { - --color-button-bg: #f59e0b; - --color-button-bg-hover: #d97706; +/* Ghost buttons */ +.btn-ghost { + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); } -/* Responsive design */ -@media (max-width: 768px) { - header { - padding: 0 var(--spacing-md); - } +.btn-ghost:not(:disabled):hover { + border-color: var(--border-strong); + color: var(--text-primary); +} - header h1 { - font-size: 1.25rem; - } +/* Mute button states in transport */ +#toggle-mute.muted { + color: var(--signal-amber); + border-color: var(--signal-amber); +} - main { - padding: var(--spacing-md); - } +/* Record button with dot indicator */ +.btn-record { + position: relative; + padding-left: calc(var(--space-5) + 10px); +} - #participants { - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: var(--spacing-sm); - } +.btn-record::before { + content: ''; + position: absolute; + left: var(--space-3); + top: 50%; + transform: translateY(-50%); + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--text-tertiary); + transition: background-color 0.2s ease; +} - #controls { - flex-direction: column; - } +.btn-record.recording::before { + background-color: var(--signal-red); + animation: rec-blink 1s steps(1) infinite; +} - button { - width: 100%; - } +@keyframes rec-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } } -@media (max-width: 480px) { - header { - padding: 0 var(--spacing-sm); - } +/* Stream button */ +.btn-stream.streaming { + color: var(--status-connected); + border-color: var(--status-connected); +} - main { - padding: var(--spacing-sm); - } +/* ========================================== + DECK PANELS — Collapsible Sections + ========================================== */ - #participants { - grid-template-columns: 1fr; - } +.deck-panel { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; } -/* Program Bus Volume Meter */ -#program-bus-section { - max-width: var(--max-width); - margin: var(--spacing-md) auto; - padding: 0 var(--spacing-md); +.deck-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-4); + cursor: pointer; + user-select: none; } -.meter-label { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text); - margin-bottom: var(--spacing-xs); - text-transform: uppercase; - letter-spacing: 0.05em; +.deck-header:hover { + background-color: var(--surface-raised); } -.meter-container { - background-color: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--spacing-sm); +.deck-summary { display: flex; align-items: center; - justify-content: center; + gap: var(--space-3); } -#volume-meter { - display: block; - border-radius: var(--radius-sm); - background-color: var(--color-bg); +.deck-label { + font-family: var(--font-display); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-tertiary); } -/* Responsive adjustments for meter */ -@media (max-width: 768px) { - #program-bus-section { - padding: 0 var(--spacing-sm); - } +.deck-toggle { + background: none; + border: none; + color: var(--text-tertiary); + font-size: var(--text-xs); + padding: var(--space-1); + cursor: pointer; + transition: transform 0.3s ease; + min-width: 0; + letter-spacing: 0; + text-transform: none; +} - #volume-meter { - width: 100%; - max-width: 400px; - } +.deck-panel.collapsed .deck-toggle { + transform: rotate(-90deg); } -@media (max-width: 480px) { - .meter-container { - padding: var(--spacing-xs); - } +.deck-body { + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--border); + transition: max-height 0.3s ease, opacity 0.3s ease; + max-height: 500px; + opacity: 1; +} - #volume-meter { - width: 100%; - } +.deck-panel.collapsed .deck-body { + max-height: 0; + opacity: 0; + padding: 0 var(--space-4); + border-top: none; + overflow: hidden; } -/* ========================= - Icecast Streaming Section - ========================= */ +/* Recording indicator */ +.recording-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--text-ghost); + transition: background-color 0.2s ease; + flex-shrink: 0; +} -#streaming-section { - max-width: var(--max-width); - margin: 0 auto var(--spacing-lg) auto; - padding: 0 var(--spacing-lg); +.recording-indicator.active { + background-color: var(--signal-red); + animation: rec-blink 1s steps(1) infinite; } -.streaming-header { +.recording-timer { + font-family: var(--font-mono); + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-tertiary); + letter-spacing: 0.05em; +} + +.recording-timer.active { + color: var(--signal-red); + text-shadow: 0 0 8px var(--signal-red-glow); +} + +.recording-controls { display: flex; align-items: center; - justify-content: space-between; - margin-bottom: var(--spacing-sm); + gap: var(--space-3); } -.streaming-label { - font-size: 0.875rem; +.recording-tracks { + margin-top: var(--space-3); +} + +.recording-tracks h4 { + font-family: var(--font-display); + font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-secondary); + letter-spacing: 0.08em; + margin-bottom: var(--space-2); + color: var(--text-tertiary); } -.streaming-status { - font-size: 0.875rem; - font-weight: 500; - padding: var(--spacing-xs) var(--spacing-sm); - border-radius: var(--radius-sm); - background-color: var(--color-bg-secondary); - color: var(--color-text-secondary); +.recording-track-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border); } -.streaming-status.streaming { - background-color: #10b981; - color: #ffffff; +.recording-track-item:last-child { + border-bottom: none; } -.streaming-status.connecting { - background-color: #f59e0b; - color: #ffffff; +.recording-track-name { + font-family: var(--font-body); + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-primary); } -.streaming-status.reconnecting { - background-color: #f59e0b; - color: #ffffff; - animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +.recording-track-size { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-tertiary); } -.streaming-status.error { - background-color: #ef4444; - color: #ffffff; +.recording-track-download { + font-family: var(--font-display); + font-size: var(--text-xs); + padding: 3px 10px; + border-radius: 2px; + background-color: transparent; + color: var(--data-cyan-dim); + border: 1px solid var(--data-cyan-dim); + cursor: pointer; + letter-spacing: 0.06em; + text-transform: uppercase; } -.streaming-controls { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-md); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background-color: var(--color-bg-secondary); - margin-bottom: var(--spacing-sm); +.recording-track-download:hover { + background-color: var(--data-cyan-glow); + color: var(--data-cyan); } -#start-streaming, -#stop-streaming { - padding: var(--spacing-sm) var(--spacing-md); - border: none; - border-radius: var(--radius-sm); - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - background-color: #10b981; - color: #ffffff; +/* ========================================== + STREAMING + ========================================== */ + +.streaming-status { + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 500; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; } -#start-streaming:hover:not(:disabled) { - background-color: #059669; +.streaming-status.streaming { + color: var(--status-connected); } -#stop-streaming { - background-color: #ef4444; +.streaming-status.connecting, +.streaming-status.reconnecting { + color: var(--status-connecting); } -#stop-streaming:hover:not(:disabled) { - background-color: #dc2626; +.streaming-status.reconnecting { + animation: pulse-status 2s ease-in-out infinite; } -#start-streaming:disabled, -#stop-streaming:disabled { - background-color: var(--color-button-disabled-bg); - color: var(--color-button-disabled-text); - cursor: not-allowed; +.streaming-status.error { + color: var(--status-error); +} + +.streaming-controls { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-3); } .streaming-config { display: flex; align-items: center; - gap: var(--spacing-xs); - margin-left: auto; + gap: var(--space-2); } .streaming-config label { - font-size: 0.875rem; + font-family: var(--font-display); + font-size: var(--text-xs); font-weight: 500; - color: var(--color-text-secondary); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; } #bitrate-select { - padding: var(--spacing-xs) var(--spacing-sm); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background-color: var(--color-bg); - color: var(--color-text); - font-size: 0.875rem; + padding: var(--space-1) var(--space-3); + border: 1px solid var(--border); + border-radius: 2px; + background-color: var(--surface-raised); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: var(--text-xs); cursor: pointer; transition: border-color 0.2s ease; } #bitrate-select:hover { - border-color: var(--color-button-bg); + border-color: var(--border-strong); } #bitrate-select:focus { outline: none; - border-color: var(--color-button-bg); + border-color: var(--signal-amber); } .streaming-info { - padding: var(--spacing-sm) var(--spacing-md); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background-color: var(--color-bg-secondary); + padding: var(--space-2) 0; } .streaming-url { - font-size: 0.875rem; - color: var(--color-text-secondary); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-tertiary); } .streaming-url a { - color: var(--color-button-bg); + color: var(--data-cyan-dim); text-decoration: none; - margin-left: var(--spacing-xs); + margin-left: var(--space-2); font-weight: 500; + padding: 2px 6px; + background-color: var(--surface-raised); + border-radius: 2px; } .streaming-url a:hover { - text-decoration: underline; + color: var(--data-cyan); + background-color: var(--surface-elevated); } -/* Responsive adjustments for streaming section */ +/* ========================================== + RESPONSIVE + ========================================== */ + @media (max-width: 768px) { - #streaming-section { - padding: 0 var(--spacing-sm); + header { + padding: 0 var(--space-4); + grid-template-columns: auto 1fr auto; + gap: var(--space-2); } - .streaming-controls { - flex-direction: column; - align-items: stretch; + .tagline { + display: none; } - .streaming-config { - margin-left: 0; - justify-content: space-between; + main { + padding: var(--space-4); + gap: var(--space-4); } - #start-streaming, - #stop-streaming { - width: 100%; + .stage { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--space-3); } -} -@media (max-width: 480px) { - .streaming-header { + .transport { flex-direction: column; - align-items: flex-start; - gap: var(--spacing-xs); } - .streaming-controls { - padding: var(--spacing-sm); + .transport button { + width: 100%; } +} - .streaming-info { - padding: var(--spacing-sm); +@media (max-width: 480px) { + header { + padding: 0 var(--space-3); } - .streaming-url { - word-break: break-all; + main { + padding: var(--space-3); } -} - -/* ========================= - Recording Section - ========================= */ - -#recording-section { - max-width: var(--max-width); - margin: 0 auto var(--spacing-lg) auto; - padding: 0 var(--spacing-lg); -} - -.recording-header { - display: flex; - align-items: center; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-sm); -} -.recording-label { - font-size: 0.875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-secondary); -} - -.recording-indicator { - width: 12px; - height: 12px; - border-radius: 50%; - background-color: var(--color-border); - transition: background-color 0.3s ease; -} - -.recording-indicator.active { - background-color: #ef4444; - animation: pulse 1.5s ease-in-out infinite; -} - -.recording-timer { - font-size: 1.25rem; - font-weight: 600; - font-family: 'SF Mono', 'Fira Code', monospace; - color: var(--color-text-secondary); - min-width: 80px; -} - -.recording-timer.active { - color: #ef4444; -} - -.recording-controls { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-md); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background-color: var(--color-bg-secondary); - margin-bottom: var(--spacing-sm); -} - -#start-recording { - background-color: #ef4444; - color: #ffffff; -} - -#start-recording:hover:not(:disabled) { - background-color: #dc2626; -} - -#stop-recording { - background-color: var(--color-button-bg); - color: #ffffff; -} - -#download-recordings { - background-color: #10b981; - color: #ffffff; -} - -#download-recordings:hover:not(:disabled) { - background-color: #059669; -} - -.recording-tracks { - padding: var(--spacing-sm) var(--spacing-md); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background-color: var(--color-bg-secondary); -} - -.recording-tracks h4 { - font-size: 0.875rem; - font-weight: 600; - margin-bottom: var(--spacing-xs); - color: var(--color-text-secondary); -} - -.recording-track-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--spacing-xs) 0; - border-bottom: 1px solid var(--color-border); -} - -.recording-track-item:last-child { - border-bottom: none; -} - -.recording-track-name { - font-size: 0.875rem; - font-weight: 500; -} - -.recording-track-size { - font-size: 0.75rem; - color: var(--color-text-secondary); -} - -.recording-track-download { - font-size: 0.75rem; - padding: 4px 8px; - border-radius: var(--radius-sm); - background-color: var(--color-button-bg); - color: #ffffff; - border: none; - cursor: pointer; -} - -.recording-track-download:hover { - background-color: var(--color-button-bg-hover); -} - -/* Recording responsive */ -@media (max-width: 768px) { - #recording-section { - padding: 0 var(--spacing-sm); + .stage { + grid-template-columns: 1fr; } - .recording-controls { + .streaming-controls { flex-direction: column; align-items: stretch; } diff --git a/web/index.html b/web/index.html index 384cc59..45f427f 100644 --- a/web/index.html +++ b/web/index.html @@ -7,6 +7,10 @@