diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 04e595d..555ebe8 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -41,6 +41,10 @@ swift_files=() cpp_files=() while IFS= read -r -d '' f; do [[ -f "$f" ]] || continue + # docs/ holds design-reference snapshots (handoff artifacts, never compiled) — out of + # format/lint/tidy scope. Mirrors .swiftlint.yml `excluded` + .swiftformat `--exclude`; + # the tools need explicit paths here, so the hook must apply the same scope itself. + case "$f" in docs/*) continue ;; esac case "$f" in *.swift) swift_files+=("$f") ;; *.h|*.hpp|*.cpp|*.cc|*.mm) cpp_files+=("$f") ;; diff --git a/.github/workflows/strict-ci.yml b/.github/workflows/strict-ci.yml index 3426fe2..0e36e3d 100644 --- a/.github/workflows/strict-ci.yml +++ b/.github/workflows/strict-ci.yml @@ -46,17 +46,33 @@ jobs: # /opt/homebrew/include so FileDecodeSource.mm's __has_include() # FFmpeg decode + metadata branch parses and clang-tidy actually analyzes it here # (that branch is analyzed NOWHERE else in the merge path); the rest are - # formatters/linters/scanners. - brew install swiftformat swiftlint llvm semgrep cppcheck ffmpeg || true + # formatters/linters/scanners. SwiftFormat/SwiftLint are NOT brewed: they are PINNED + # below (brew-latest 0.62.x changed wrapIfStatementBodies and flagged 74 files the + # local 0.61.1 gate passes — PR #61 run 1); strict-gate.sh asserts the pins, so a + # skew fails loudly with instructions instead of red-herring style errors. + brew install llvm semgrep cppcheck ffmpeg || true # Periphery (Swift dead-code detection) lives in a tap, not core Homebrew — install it # separately so `make strict-gate`'s hostile `periphery scan` step has the binary. brew install peripheryapp/periphery/periphery || true + # Pinned style tools (versions MUST match strict-gate.sh's SWIFTFORMAT_PIN / + # SWIFTLINT_PIN; a deliberate upgrade bumps both places + reformats in one commit). + mkdir -p "$HOME/pinned-tools" + curl -fsSL -o /tmp/swiftformat.zip \ + https://github.com/nicklockwood/SwiftFormat/releases/download/0.61.1/swiftformat.zip + unzip -o -q /tmp/swiftformat.zip -d "$HOME/pinned-tools" + curl -fsSL -o /tmp/swiftlint.zip \ + https://github.com/realm/SwiftLint/releases/download/0.64.1/portable_swiftlint.zip + unzip -o -q /tmp/swiftlint.zip -d "$HOME/pinned-tools" + chmod +x "$HOME/pinned-tools/swiftformat" "$HOME/pinned-tools/swiftlint" # Prepend keg-only LLVM (clang-tidy) + Homebrew bin for later steps, PRESERVING the # runner default PATH (/usr/bin, where make lives). A step-level # `env: PATH: …:${{ env.PATH }}` does NOT work — the Actions env context has no PATH, # so it expanded to empty, dropped /usr/bin, and broke `make` (exit 127). + # pinned-tools is written LAST so it lands FIRST on PATH (each write prepends), + # shadowing any preinstalled brew swiftformat/swiftlint on the runner image. echo "/opt/homebrew/opt/llvm/bin" >> "$GITHUB_PATH" echo "/opt/homebrew/bin" >> "$GITHUB_PATH" + echo "$HOME/pinned-tools" >> "$GITHUB_PATH" - name: Run strict gate run: make strict-gate diff --git a/.semgrep.yml b/.semgrep.yml index 6be44e2..4b74cc3 100644 --- a/.semgrep.yml +++ b/.semgrep.yml @@ -66,3 +66,74 @@ rules: paths: include: ['*.c', '*.cc', '*.cpp', '*.mm', '*.h', '*.hpp'] pattern-regex: '\.detach\s*\(' + + # --- S10.7 layer-policy tripwires (docs/sprints/s10-7-liquid-glass-design.md §3.1/§3.2/§7 R1). + # Drift nets, not security boundaries (same posture as the migrator grep): helper indirection + # and legal-token recomposition can evade regexes — review owns that seam (design §7.4-1). + # The four DEFINITION files (DesignSystem, DesignSystemGlass, Color+Brand, SpectrumColorPalette) + # are the only exceptions — they are where color/material IS defined. + + - id: ui-no-adhoc-material + languages: [swift] + severity: ERROR + message: >- + Backdrop-sampling material outside the token layer. App glass-look surfaces are + token-governed fills via .glassPanel(_:in:) (design §3.1 Regime B); the ONE sanctioned + Material use (a transient overlay with variable content genuinely beneath) is the + .overlay role inside DesignSystemGlass.swift. Declare a role, don't paint a material. + paths: + include: ['Sources/AdaptiveSound'] + exclude: ['DesignSystem.swift', 'DesignSystemGlass.swift', 'Color+Brand.swift', 'SpectrumColorPalette.swift'] + pattern-regex: '\.(ultraThin|thin|regular|thick|ultraThick)Material\b|\bMaterial\.|\bNSVisualEffectView\b|\.background\(\s*\.bar\b' + + - id: no-liquid-glass-api + languages: [swift] + severity: ERROR + message: >- + Liquid Glass API use is FROZEN for S10.7 — no app surface has variable content beneath + it (design §3.1 placement test), so glassEffect would be cited "ghost glass". This rule + is NEVER suppressed: adopting real glass means EDITING THIS RULE in the same PR as the + design review that sanctions the under-content surface (see DesignSystemGlass.swift's + header). Suppression = hack; rule edit + design review = policy evolution. + paths: + include: ['Sources'] + pattern-regex: '\.glassEffect(ID|Union|Transition)?\s*\(|\bGlassEffectContainer\b|\bNSGlassEffectView\b|buttonStyle\(\s*\.glass' + + - id: ui-no-color-literal + languages: [swift] + severity: ERROR + message: >- + Hand-painted color in a view. All color comes from DesignTokenKit data via DesignSystem + tokens (light+dark pairs, permanently contrast-audited by R4) so appearance, Reduce + Transparency, and Increase Contrast stay correct everywhere. Add/extend a Palette token + instead. (Color.clear is deliberately allowed — absence of paint, not a color; hierarchical + styles like .secondary are vibrancy styles, not colors, and are also fine.) + paths: + include: ['Sources/AdaptiveSound'] + # PATH-ANCHORED (break-it hardening): bare basenames matched ANY file with that name + # anywhere under the include — a new UI/Foo/DesignSystem.swift was silently exempt. + exclude: + - 'Sources/AdaptiveSound/DesignSystem.swift' + - 'Sources/AdaptiveSound/DesignSystemGlass.swift' + - 'Sources/AdaptiveSound/Color+Brand.swift' + - 'Sources/AdaptiveSound/UI/Spectrum/SpectrumColorPalette.swift' + pattern-either: + # Colorspace prefix generalized (break-it hardening): `Color(.displayP3, red:)` evaded + # the .sRGB-only group — and P3 is exactly what the R4 audit cannot model. + - pattern-regex: '\b(Color|NSColor)\(\s*(\.[A-Za-z0-9]+\s*,\s*)?(srgbRed|calibratedRed|deviceRed|red|white|hue)\s*:|#colorLiteral|\bColor\(\s*named:|\bColor\(\s*"' + - pattern-regex: '\.(foregroundStyle|foregroundColor|fill|tint|stroke|strokeBorder|background|overlay|border)\(\s*(SwiftUI\.Color\.|Color\.|\.)(red|black|white|blue|green|orange|yellow|pink|purple|teal|mint|cyan|indigo|brown|gray)\b' + - pattern-regex: '\.shadow\(\s*color:\s*(SwiftUI\.Color\.|Color\.|\.)(red|black|white|blue|green|orange|yellow|pink|purple|teal|mint|cyan|indigo|brown|gray)\b' + - pattern-regex: 'NSColor\.system(Red|Blue|Green|Orange|Yellow|Pink|Purple|Teal|Mint|Cyan|Indigo|Brown|Gray)\b' + + - id: ui-no-appearance-branching + languages: [swift] + severity: ERROR + message: >- + Per-view appearance branching. Appearance is owned by the dynamic token layer (S9-T + posture, enforced since S10.7) and by the .glassPanel env->resolver shim in + DesignSystemGlass.swift; forcing preferredColorScheme breaks follow-the-system. Use + DesignSystem tokens. + paths: + include: ['Sources/AdaptiveSound'] + exclude: ['DesignSystemGlass.swift'] + pattern-regex: '\\\.colorScheme\b|\.preferredColorScheme\s*\(|NSApp\.appearance\s*=' diff --git a/.swiftformat b/.swiftformat index f783421..fe0d2de 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,5 +1,9 @@ # SwiftFormat rules for AdaptiveSound +# Design-reference snapshots under docs/ are handoff artifacts, not governed source — +# never reformat them (mirrors .swiftlint.yml `excluded` and the pre-commit hook scope). +--exclude docs + # Keep opening braces on the same line as a multi-line condition (K&R), matching # SwiftLint's `opening_brace` rule. Without this, swiftformat wraps the brace to its # own line on wrapped `if/guard/while` conditions, which swiftlint then flags — the two diff --git a/.swiftlint.yml b/.swiftlint.yml index 53ea10d..2177fe4 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -39,6 +39,10 @@ excluded: - .build/ - .swiftpm/ - Pods/ + # Design-reference snapshots (e.g. docs/design/*/NowPlayingView.swift) are handoff + # artifacts, not governed source — they are never compiled. The pre-commit hook and + # .swiftformat mirror this same scope. + - docs/ # --- Severity promotions (guide §4.2) --- force_cast: error diff --git a/Package.swift b/Package.swift index 5368a0f..b4abd06 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,7 @@ let package = Package( name: "AdaptiveSound", dependencies: [ "AudioDSP", "AudioFormatKit", "LibraryStore", "LibraryScan", "PlaybackQueueKit", - "LibraryBrowseKit", + "LibraryBrowseKit", "DesignTokenKit", ], path: "Sources/AdaptiveSound", // Info.plist is consumed by scripts/bundle-app.py, not by SwiftPM. @@ -150,6 +150,16 @@ let package = Package( dependencies: ["LibraryStore"], path: "Sources/LibraryBrowseKit" ), + // Design-token data + pure surface/accessibility resolvers (S10.7 R0). Same rationale + // as PlaybackQueueKit/LibraryBrowseKit: extracted from the non-importable app target so + // the token values, the RT/IC resolution, and the WCAG contrast audit are headlessly + // gated forever. ZERO SwiftUI/AppKit imports (strict-gate purity guard) — the app-side + // DesignSystemGlass.swift bridges this data into SwiftUI values (single source). + .target( + name: "DesignTokenKit", + dependencies: [], + path: "Sources/DesignTokenKit" + ), // Pure-Swift ViewModel tests — uses the Swift Testing framework shipped // with CLT (Testing.framework), not XCTest. .testTarget( @@ -198,6 +208,14 @@ let package = Package( dependencies: ["LibraryBrowseKit", "LibraryStore"], path: "Tests/LibraryBrowseKitTests" ), + // S10.7 rig (design §7): RES resolver cube, TOK palette invariants, the PERMANENT R4 + // contrast audit (pure WCAG math over Kit data), and the SlotFit truncation net. The + // TEST target may import AppKit (font measurement); the Kit itself may not. + .testTarget( + name: "DesignTokenKitTests", + dependencies: ["DesignTokenKit"], + path: "Tests/DesignTokenKitTests" + ), // Obj-C++ bridge: wraps EQModule and EQModuleCoefficients in a pure-C // interface (EQTestBridge.h) for Swift test consumption. // diff --git a/Sources/AdaptiveSound/AudioBridgeError.swift b/Sources/AdaptiveSound/AudioBridgeError.swift index 5688710..1e5cf56 100644 --- a/Sources/AdaptiveSound/AudioBridgeError.swift +++ b/Sources/AdaptiveSound/AudioBridgeError.swift @@ -6,3 +6,16 @@ enum AudioBridgeError: Error { case engineNotInitialized case unsupportedFormat(String) } + +/// Founder-facing copy (break-it): without this, the error banner rendered the raw bridged +/// enum — "The operation couldn't be completed. (AdaptiveSound.AudioBridgeError error 0.)". +extension AudioBridgeError: LocalizedError { + var errorDescription: String? { + switch self { + case .engineNotInitialized: + "The audio engine isn't ready yet — try again in a moment." + case let .unsupportedFormat(ext): + "This file type isn't supported (.\(ext))." + } + } +} diff --git a/Sources/AdaptiveSound/AudioEngineBridge+ConfigChange.swift b/Sources/AdaptiveSound/AudioEngineBridge+ConfigChange.swift index 4286eba..17a2f88 100644 --- a/Sources/AdaptiveSound/AudioEngineBridge+ConfigChange.swift +++ b/Sources/AdaptiveSound/AudioEngineBridge+ConfigChange.swift @@ -90,6 +90,30 @@ extension AudioEngineBridge { } } + // Refresh the device rate: a config change can be a switch to a device running at a + // different rate, so the stored Enhanced snapshot's achievedSampleRate would otherwise + // go stale. One field only (mutate, not store) so fellBackToEnhanced/interrupted stand. + // On configChangeQueue here — the engine is settled, so this outputNode read is safe. + mutateSignalPath { $0.achievedSampleRate = engine.outputNode.outputFormat(forBus: 0).sampleRate } + + // Re-validate the play INTENT immediately before touching the player (break-it + // MAJOR-2): a user Stop can complete on engineQueue between the read at the top of + // this function and here — re-scheduling after a deliberate stop is zombie audio + // under a stopped UI. The stop already tore playback down; undo our engine restart. + let intentStillLive = resampleQueue.sync { enhancedPlayIntent } + guard intentStillLive else { + engine.stop() + logUX("config-change: play intent cleared mid-reestablish — engine stopped, not resuming") + return + } + + resumeEnhancedAfterReestablish(player: player, resumePos: resumePos) + } + + /// The resume tail of `reestablishEnhancedAfterConfigChange` (split for the complexity + /// limit when the intent re-check joined): re-prime/re-schedule from the playhead per + /// sub-path. Runs on `configChangeQueue` with the engine already restarted. + private func resumeEnhancedAfterReestablish(player: AVAudioPlayerNode, resumePos: Double) { // Read resampleSession under the queue's lock so the branch decision is consistent with // any in-flight mutation on resampleQueue (avoids a TOCTOU race with seekEnhancedResampler // or primeEnhancedResamplerLocked running concurrently). diff --git a/Sources/AdaptiveSound/AudioEngineBridge+EnhancedResampler.swift b/Sources/AdaptiveSound/AudioEngineBridge+EnhancedResampler.swift index ec05eeb..c1358cc 100644 --- a/Sources/AdaptiveSound/AudioEngineBridge+EnhancedResampler.swift +++ b/Sources/AdaptiveSound/AudioEngineBridge+EnhancedResampler.swift @@ -183,9 +183,13 @@ extension AudioEngineBridge { guard let session = resampleSession else { return } capturedSession = session - // Bump generation FIRST so any completion that fires mid-seek schedules nothing onto - // the about-to-be-restarted player. + // Bump BOTH generations FIRST so any completion that fires mid-seek schedules + // nothing onto the about-to-be-restarted player. The passthrough bump is + // defense-in-depth (review MAJOR-1): if stale state ever routes a passthrough + // session through THIS branch, its live .dataPlayedBack completion must abandon + // on the stop below rather than roll the seam. resampleGeneration &+= 1 + passthroughGeneration &+= 1 let fileRate = session.inputFormat.sampleRate guard fileRate > 0 else { capturedSession = nil; return } @@ -244,12 +248,15 @@ extension AudioEngineBridge { } /// Teardown body run WITHOUT dispatching — the caller MUST already be on `resampleQueue`. - /// Bumps the generation (in-flight read→convert→schedule iterations and pending completions - /// abandon themselves), drops the session, and clears both gapless EOF hooks. Used by the - /// gapless seam handlers (which run on resampleQueue); off-queue callers use - /// `stopEnhancedResampler()`, which wraps this in `resampleQueue.sync`. + /// Bumps BOTH generations (in-flight resampler iterations AND pending passthrough + /// `.dataPlayedBack` completions abandon themselves — `player.stop()` fires the latter, + /// indistinguishable from a real EOF), drops the session, and clears both gapless EOF + /// hooks. Used by the gapless seam handlers (which run on resampleQueue); off-queue + /// callers use `stopEnhancedResampler()`, which wraps this in `resampleQueue.sync`. func stopEnhancedResamplerLocked() { + dispatchPrecondition(condition: .onQueue(resampleQueue)) resampleGeneration &+= 1 + passthroughGeneration &+= 1 resampleSession = nil onResamplerEOF = nil onPassthroughEOF = nil diff --git a/Sources/AdaptiveSound/AudioEngineBridge+EnhancedResamplerSeam.swift b/Sources/AdaptiveSound/AudioEngineBridge+EnhancedResamplerSeam.swift index 99ee70f..42a8807 100644 --- a/Sources/AdaptiveSound/AudioEngineBridge+EnhancedResamplerSeam.swift +++ b/Sources/AdaptiveSound/AudioEngineBridge+EnhancedResamplerSeam.swift @@ -44,10 +44,11 @@ extension AudioEngineBridge { // generation, so this session is stale — schedule nothing more (cleanly abandons chaining). guard isCurrent(generation: generation, session: session) else { return false } if session.reachedEnd { - // Current session is exhausted. Fire the gapless EOF hook (if armed by the gapless - // extension) so the next track can be scheduled without stopping the player. The hook - // runs here on resampleQueue, which is where all session state is serialized. - onResamplerEOF?(session, player) + // Current session is exhausted. Fire the gapless EOF hook so the next track can be + // scheduled without stopping the player — or, with NO hook armed (final track / + // single track), surface the ended state (break-it BLOCKER-1). Runs on + // resampleQueue, where all session state is serialized. + fireResamplerEOFOrEnd(session: session, player: player) return false } diff --git a/Sources/AdaptiveSound/AudioEngineBridge+GaplessRoll.swift b/Sources/AdaptiveSound/AudioEngineBridge+GaplessRoll.swift index cf49b71..1679691 100644 --- a/Sources/AdaptiveSound/AudioEngineBridge+GaplessRoll.swift +++ b/Sources/AdaptiveSound/AudioEngineBridge+GaplessRoll.swift @@ -102,15 +102,47 @@ extension AudioEngineBridge { /// intent), exactly as the inline EOF guards did. MUST be called on `resampleQueue`. private func consumeOnDeckURL(context: String) -> URL? { guard let nextURL = onDeckURL else { - gaplessPlaybackEnded = true - enhancedPlayIntent = false // queue exhausted — don't auto-resume on a later config change - logUX("gapless: \(context) EOF — no next track, playback ended") + markGaplessPlaybackEnded(reason: "\(context) EOF — no next track, playback ended") return nil } onDeckURL = nil // consume the armed URL return nextURL } + /// Fire the passthrough EOF hook — or, when NO hook is armed under a LIVE epoch, surface + /// the ended state (break-it BLOCKER-1): `setNextTrack(nil)` clears the hook at the FINAL + /// seam and a single-track queue never arms one, so the last real EOF used to be + /// swallowed — `isPlaying` stayed true and the VM polled a silent engine forever (the + /// footer counted past the duration). A live-epoch completion with a nil hook is provably + /// a genuine EOF: every non-EOF stop (pause/stop/seek/track-change/Pure entry/device + /// loss/re-establish) bumps the epoch BEFORE its `player.stop()`. MUST be called on + /// `resampleQueue` with the epoch already validated by the caller. + func firePassthroughEOFOrEnd(player: AVAudioPlayerNode) { + if let hook = onPassthroughEOF { + hook(player) + } else { + markGaplessPlaybackEnded(reason: "passthrough EOF — no seam armed, playback ended") + } + } + + /// The resampler twin: a `reachedEnd` session under a CURRENT generation with no + /// `onResamplerEOF` armed is the same genuine end. MUST be called on `resampleQueue`. + func fireResamplerEOFOrEnd(session: EnhancedResampleSession, player: AVAudioPlayerNode) { + if let hook = onResamplerEOF { + hook(session, player) + } else { + markGaplessPlaybackEnded(reason: "resampler EOF — no seam armed, playback ended") + } + } + + /// The ONE ended-state surface (the channel `AudioViewModel.tickTransport` polls): + /// pioneered by `consumeOnDeckURL`'s empty branch, now shared by the nil-hook EOF paths. + private func markGaplessPlaybackEnded(reason: String) { + gaplessPlaybackEnded = true + enhancedPlayIntent = false // ended — don't auto-resume on a later config change + logUX("gapless: \(reason)") + } + /// Open `nextURL` for reading. On success returns the file (and releases the security scope, /// since `AVAudioFile` retains its own handle). On failure, falls back to a clean restart on /// the global queue — and if THAT also fails, sets `gaplessPlaybackEnded` on `resampleQueue` @@ -159,12 +191,21 @@ extension AudioEngineBridge { player: AVAudioPlayerNode, resetFramePosition: Bool ) { + dispatchPrecondition(condition: .onQueue(resampleQueue)) if resetFramePosition { file.framePosition = 0 } + // Capture the CURRENT passthrough epoch (we're on resampleQueue, the owner; no bump — + // a seam schedule replaces one whose completion already fired legitimately). A later + // stop/seek/reschedule bumps the epoch, and this completion then abandons at fire time + // instead of rolling the seam off a non-EOF `player.stop()` (wrong-song bug). + let gen = passthroughGeneration player.scheduleFile( file, at: nil, completionCallbackType: .dataPlayedBack ) { [weak self, weak player] _ in guard let self, let livePlayer = player else { return } - self.resampleQueue.async { self.onPassthroughEOF?(livePlayer) } + self.resampleQueue.async { + guard gen == self.passthroughGeneration else { return } // superseded + self.firePassthroughEOFOrEnd(player: livePlayer) + } } } @@ -183,6 +224,12 @@ extension AudioEngineBridge { ) { guard let converter = makeConverter(for: nextFile, player: player) else { // Converter creation failed: fall back to scheduleFile (brief gap, chain preserved). + // Clear the OLD track's exhausted session first (the P1-2 rule, same as the + // empty-prime branch below): leaving it non-nil misroutes a later seek/reestablish + // to the resampler branch, whose stop() bumps only resampleGeneration — the live + // passthrough schedule would fire under an unbumped epoch and roll the seam + // (the wrong-song bug escaping through stale state; seam-fix review MAJOR-1). + resampleSession = nil // S-1: the .dataPlayedBack completion keeps onPassthroughEOF firing for the next seam. bumpTransitionCount(player: player) armPassthroughNextTrack(player: player) diff --git a/Sources/AdaptiveSound/AudioEngineBridge+Playback.swift b/Sources/AdaptiveSound/AudioEngineBridge+Playback.swift index 2d48170..17ac0b9 100644 --- a/Sources/AdaptiveSound/AudioEngineBridge+Playback.swift +++ b/Sources/AdaptiveSound/AudioEngineBridge+Playback.swift @@ -106,9 +106,15 @@ extension AudioEngineBridge { setActivePath(.enhanced) resampleQueue.sync { enhancedPlayIntent = true } let fellBack = loadSignalPath().fellBackToEnhanced + // The device's actual output rate (the honest "device is running at" value, per the + // field's doc + the D5 device-pill readout). Enhanced processes internally at the graph + // rate; the outputNode format reflects the hardware, so a 48 kHz device reads "48 kHz". + // Captured on the engine queue (engine settled + playing here) — the same store-time + // discipline the Pure path uses via makeSignalPathInfo, never a MainActor engine read. storeSignalPath(SignalPathInfo( path: .enhanced, decision: .fallbackEnhanced, + achievedSampleRate: engine.outputNode.outputFormat(forBus: 0).sampleRate, fellBackToEnhanced: fellBack )) } @@ -169,13 +175,21 @@ extension AudioEngineBridge { // delay/padding via the file's edit list (kExtAudioFileProperty_ClientDataFormat + // kAFInfoDictionary_ApproximateDuration). Apple handles this automatically on this // path; we must NOT disable or override it. Pure/FFmpeg trim is Stage 2. + // Capture the passthrough epoch this schedule is installed under (the + // `stopEnhancedResampler()` above bumped it, abandoning any completion the + // `playerNode.stop()` fired): a later stop/seek/reschedule bumps again, and this + // completion must then abandon instead of rolling the gapless seam (wrong-song bug). + let passthroughGen: UInt64 = resampleQueue.sync { passthroughGeneration } playerNode.scheduleFile( audioFile, at: nil, completionCallbackType: .dataPlayedBack ) { [weak self, weak playerNode] _ in guard let self, let livePlayer = playerNode else { return } - self.resampleQueue.async { self.onPassthroughEOF?(livePlayer) } + self.resampleQueue.async { + guard passthroughGen == self.passthroughGeneration else { return } // superseded + self.firePassthroughEOFOrEnd(player: livePlayer) + } } playerNode.play() logUX("Enhanced started '\(fileURL.lastPathComponent)' (\(Int(graphRate)) passthrough)") diff --git a/Sources/AdaptiveSound/AudioEngineBridge+PureMode.swift b/Sources/AdaptiveSound/AudioEngineBridge+PureMode.swift index 12c3b22..0a57331 100644 --- a/Sources/AdaptiveSound/AudioEngineBridge+PureMode.swift +++ b/Sources/AdaptiveSound/AudioEngineBridge+PureMode.swift @@ -306,6 +306,17 @@ extension AudioEngineBridge { guard targetFrame >= 0, targetFrame < totalFrames else { return } let frameCount = AVAudioFrameCount(totalFrames - targetFrame) + // Bump the passthrough epoch BEFORE the stop: `player.stop()` fires the superseded + // schedule's `.dataPlayedBack` completion, which — with a next track armed — would + // otherwise roll the gapless seam and start the WRONG song (the founder's device-switch + // bug; user seek on a 48 kHz file had the same hazard). The new segment is installed + // under the new epoch; the stale completion abandons on the mismatch. Callers run on + // engineQueue (seek) / configChangeQueue (re-establish) — never resampleQueue — so the + // sync is deadlock-safe (the stopEnhancedResampler direction, C-1 discipline). + let gen: UInt64 = resampleQueue.sync { + passthroughGeneration &+= 1 + return passthroughGeneration + } player.stop() audioFile.framePosition = targetFrame player.scheduleSegment( @@ -316,7 +327,10 @@ extension AudioEngineBridge { completionCallbackType: .dataPlayedBack ) { [weak self, weak player] _ in guard let self, let livePlayer = player else { return } - self.resampleQueue.async { self.onPassthroughEOF?(livePlayer) } + self.resampleQueue.async { + guard gen == self.passthroughGeneration else { return } // superseded + self.firePassthroughEOFOrEnd(player: livePlayer) + } } player.play() } diff --git a/Sources/AdaptiveSound/AudioEngineBridge+PureModeDeviceMonitor.swift b/Sources/AdaptiveSound/AudioEngineBridge+PureModeDeviceMonitor.swift index 0d8576e..a1a68dc 100644 --- a/Sources/AdaptiveSound/AudioEngineBridge+PureModeDeviceMonitor.swift +++ b/Sources/AdaptiveSound/AudioEngineBridge+PureModeDeviceMonitor.swift @@ -89,6 +89,13 @@ extension AudioEngineBridge { // (off resampleQueue), so the sync is deadlock-safe. resampleQueue.sync { enhancedPlayIntent = false } tearDownPure() // stops + destroys the Pure engine, unregisters the alive listener + // Belt-and-braces before the stop (review MINOR-3): normally Pure entry already + // cleared the hooks + bumped the epochs, but if a queued device-loss callback races + // a just-started Enhanced session, this player.stop() would fire a live-epoch + // completion with an armed hook → a stale seam roll while "interrupted". Clearing + // here makes the stop unconditionally roll-proof (configChangeQueue → sync is the + // P1-B-safe direction). + stopEnhancedResampler() if let player = playerNodeRef, player.isPlaying { player.stop() } diff --git a/Sources/AdaptiveSound/AudioEngineBridge.swift b/Sources/AdaptiveSound/AudioEngineBridge.swift index f985756..efe85c5 100644 --- a/Sources/AdaptiveSound/AudioEngineBridge.swift +++ b/Sources/AdaptiveSound/AudioEngineBridge.swift @@ -158,6 +158,15 @@ final class AudioEngineBridge: AudioPlaybackEngine, @unchecked Sendable { /// set `gaplessPlaybackEnded`. Cleared on stop/seek so a stale handler cannot fire. var onPassthroughEOF: ((AVAudioPlayerNode) -> Void)? + /// Epoch for the 48 kHz passthrough schedules — the passthrough twin of `resampleGeneration`. + /// `player.stop()` FIRES any pending `.dataPlayedBack` completion, indistinguishable from a + /// real end-of-track: with a next track armed, the seam handler would consume it and start + /// the WRONG song on every stop that isn't an EOF (device-switch re-establish and passthrough + /// seek both stop+reschedule — the founder's wrong-song/stale-selection bug). Every schedule + /// site captures the epoch it was installed under; the completion abandons at fire time if a + /// stop/seek/reschedule has advanced it. Owned by `resampleQueue`, like all seam state. + var passthroughGeneration: UInt64 = 0 + /// Called on `resampleQueue` when the streaming resampler reaches EOF and is about to stop /// chaining. The gapless extension installs this to roll into the next track without a gap. /// The handler receives the session that just ended and the live player node. Cleared on stop / diff --git a/Sources/AdaptiveSound/AudioViewModel+Devices.swift b/Sources/AdaptiveSound/AudioViewModel+Devices.swift index a015b6e..ef66324 100644 --- a/Sources/AdaptiveSound/AudioViewModel+Devices.swift +++ b/Sources/AdaptiveSound/AudioViewModel+Devices.swift @@ -44,8 +44,20 @@ extension AudioViewModel { let engineDeviceID = engine.currentOutputDeviceID() if let target = devices.first(where: { $0.id == engineDeviceID }) { selectedDevice = target - } else if !(selectedDevice.map { sel in devices.contains { $0.id == sel.id } } ?? false) { - selectedDevice = devices.first + } else { + // Break-it MAJOR-1: the engine's target DIED (mid-play disconnect / PIN + // re-assert failure). Keep a listed selection (or fall to the first device) + // and PUSH it to the engine — otherwise the picker and engine diverge and + // every Pure viability check runs against the corpse id (permanent "Pure + // unavailable" until a manual re-pick). Converges: once re-asserted, the + // engine target is listed again and this branch stops firing. + if !(selectedDevice.map { sel in devices.contains { $0.id == sel.id } } ?? false) { + selectedDevice = devices.first + } + if let chosen = selectedDevice { + logUX("refreshDevices: engine target \(engineDeviceID) gone — re-asserting '\(chosen.name)'") + selectDevice(chosen) + } } // QW-C: auto-disable crossfeed if the new selected device is not a headphone type. if crossfeedEnabled && !deviceIsHeadphones { diff --git a/Sources/AdaptiveSound/AudioViewModel+Lifecycle.swift b/Sources/AdaptiveSound/AudioViewModel+Lifecycle.swift index 2ad9600..bdd12c9 100644 --- a/Sources/AdaptiveSound/AudioViewModel+Lifecycle.swift +++ b/Sources/AdaptiveSound/AudioViewModel+Lifecycle.swift @@ -99,13 +99,16 @@ extension AudioViewModel { // P2-C: ordered teardown — `engine.shutdown()` runs only AFTER `performStop()` has fully // completed, so shutdown can't tear down `avEngine`/`loudnessMeter`/`pureEngine` while // `stopAudio()` is still mid-flight on another thread → use-after-free of the C handles. + // Close the play-verb gate FIRST (break-it): during the two awaits below, a play from + // the menu bar / Control Center could pass the `isEngineReady` guard and land on + // engineQueue AFTER the teardown block → "engine not initialized" at quit. + isEngineReady = false await performStop() do { try await engine.shutdown() } catch { errorMessage = "Engine shutdown failed: \(error.localizedDescription)" } - isEngineReady = false logUX("shutdown — complete") } diff --git a/Sources/AdaptiveSound/AudioViewModel+Playback.swift b/Sources/AdaptiveSound/AudioViewModel+Playback.swift index 526ea4d..853ab7c 100644 --- a/Sources/AdaptiveSound/AudioViewModel+Playback.swift +++ b/Sources/AdaptiveSound/AudioViewModel+Playback.swift @@ -65,6 +65,13 @@ extension AudioViewModel { errorMessage = "Playback failed: \(error.localizedDescription)" isPlaying = false pendingNextIndex = nil + // Self-heal (break-it): engine-not-ready is recoverable — rebuild the graph + // so the NEXT play press succeeds. `retryInitialization` is internally + // guarded against re-entry while an init is already in flight. + if case AudioBridgeError.engineNotInitialized = error { + logUX("startPlayback: engine not initialized — self-healing via retryInitialization") + retryInitialization() + } } } } diff --git a/Sources/AdaptiveSound/AudioViewModel+SpectrumTimer.swift b/Sources/AdaptiveSound/AudioViewModel+SpectrumTimer.swift index a2e99be..3f82c5a 100644 --- a/Sources/AdaptiveSound/AudioViewModel+SpectrumTimer.swift +++ b/Sources/AdaptiveSound/AudioViewModel+SpectrumTimer.swift @@ -57,18 +57,13 @@ extension AudioViewModel { // scrubber at the paused spot. Only a genuine stop (not playing, not paused) zeroes it. if isPlaying { playbackPosition = engine.currentPlaybackPosition() ?? playbackPosition - } else if pausedResumePosition == nil { + } else if pausedResumePosition == nil, playbackPosition != 0 { playbackPosition = 0 } // S10.6: accrue ≥60%-heard play-through time (advances the monotonic reference every tick; // accrues only while playing → a pause/stall/seek never mis-counts). accruePlayThrough() - loudness = engine.currentLoudness() - var freshPath = engine.currentSignalPath() - // F4: copy enhancement overlay fields so the badge is a pure function of the snapshot. - freshPath.intensityLinear = intensity - freshPath.crossfeedStrength = crossfeedEnabled ? crossfeedStrength : nil - signalPath = freshPath + pollEngineReadouts() // The output device disappeared (e.g. Bluetooth disconnected) and the engine paused — // reflect it in the UI and prompt the user to pick a device. @@ -130,9 +125,35 @@ extension AudioViewModel { /// Called at 20 Hz on the main thread by the SPECTRUM timer. Reads the latest band magnitudes /// from the double-buffer, interpolates 44 bands → 88 display bars, and writes into /// `spectrumBars` to trigger SwiftUI observation. Pure visualizer work — no transport state. + /// Poll loudness + the signal path, SUPPRESSING same-value writes (break-it MINOR-3): + /// Observation has no equality gate of its own, so unconditional assignment re-evaluated + /// every signalPath/loudness observer (pill, hero badges, inspector, footer slot) 20×/s + /// even while idle. These guards are what makes SignalPathInfo's "Equatable so redundant + /// renders are suppressed" doc comment actually true. + @MainActor + private func pollEngineReadouts() { + let freshLoudness = engine.currentLoudness() + if loudness != freshLoudness { loudness = freshLoudness } + var freshPath = engine.currentSignalPath() + // F4: copy enhancement overlay fields so the badge is a pure function of the snapshot. + freshPath.intensityLinear = intensity + freshPath.crossfeedStrength = crossfeedEnabled ? crossfeedStrength : nil + if signalPath != freshPath { signalPath = freshPath } + } + @MainActor func tickSpectrum() { - guard engine.readSpectrumBands(into: &spectrumScratch) else { return } + guard engine.readSpectrumBands(into: &spectrumScratch) else { + // Pure mode installs no spectrum tap (bit-perfect path) — without this reset the + // lens FROZE at the last Enhanced frame at full opacity, a static lie during the + // flagship mode (break-it). Zero once; the contains-check keeps it idempotent. + if signalPath.path == .pure, spectrumBars.contains(where: { $0 > 0 }) { + spectrumBars = [Float](repeating: 0, count: SpectrumConstants.displayBarCount) + peakCaps = [Float](repeating: 0, count: SpectrumConstants.displayBarCount) + peakTracker.reset(to: []) + } + return + } // Upsample 44 bands → 88 bars by linear interpolation between adjacent bands. // Bar i maps to fractional band position i / 2.0 (even bars fall on band centres). let bandCount = SpectrumConstants.bandCount @@ -144,5 +165,13 @@ extension AudioViewModel { let weight = frac - Float(lower) spectrumBars[bar] = spectrumScratch[lower] * (1 - weight) + spectrumScratch[upper] * weight } + // Peak-hold caps (S10.7 PR 3): fed the SAME tick, nominal 20 Hz step — belt-and- + // braces isPlaying gate so caps freeze even if the engine keeps producing frames + // while paused (the tracker itself freezes structurally when unfed). Both arrays + // mutate in one run-loop turn → one @Observable invalidation. + if isPlaying { + peakTracker.update(bars: spectrumBars.map(Double.init), elapsed: 1.0 / 20.0) + peakCaps = peakTracker.caps.map(Float.init) + } } } diff --git a/Sources/AdaptiveSound/AudioViewModel.swift b/Sources/AdaptiveSound/AudioViewModel.swift index f201ca0..fe57818 100644 --- a/Sources/AdaptiveSound/AudioViewModel.swift +++ b/Sources/AdaptiveSound/AudioViewModel.swift @@ -1,3 +1,4 @@ +import DesignTokenKit import Foundation import PlaybackQueueKit @@ -166,6 +167,12 @@ final class AudioViewModel { /// Updated on the main thread at ~20 Hz by `spectrumTimer`. /// Index 0 = lowest frequency band; index 87 = highest. var spectrumBars: [Float] = .init(repeating: 0, count: SpectrumConstants.displayBarCount) + /// Peak-hold caps above the bars (S10.7 PR 3): published beside `spectrumBars` from the + /// same 20 Hz tick so `@Observable` coalesces both into ONE view invalidation. Values + /// resolve in `DesignTokenKit.PeakHoldTracker` (pure, time-fed — freeze-on-pause is + /// structural: no feed, no decay). + var peakCaps: [Float] = .init(repeating: 0, count: SpectrumConstants.displayBarCount) + var peakTracker = PeakHoldTracker(bandCount: SpectrumConstants.displayBarCount) /// Retained so we can invalidate on shutdown. /// Internal (not private) so `AudioViewModel+SpectrumTimer.swift` can read and invalidate it. diff --git a/Sources/AdaptiveSound/DesignSystem.swift b/Sources/AdaptiveSound/DesignSystem.swift index 9633105..1df028f 100644 --- a/Sources/AdaptiveSound/DesignSystem.swift +++ b/Sources/AdaptiveSound/DesignSystem.swift @@ -1,4 +1,5 @@ import AppKit +import DesignTokenKit import SwiftUI // MARK: - Design System (canonical visual tokens) @@ -18,55 +19,79 @@ enum DesignSystem { /// An appearance-reactive color (S9-T): `dark` is served under `.darkAqua`, `light` /// otherwise, via an `NSColor` dynamic provider — so surfaces/labels follow the /// system appearance with NO per-view `colorScheme` checks (D2: build light+dark, - /// not dark-lock). `dark` values are the pre-S9-T look, unchanged; `light` values - /// are a first pass to tune during the founder `make run` in Light Appearance. - static func dynamic(light: SwiftUI.Color, dark: SwiftUI.Color) -> SwiftUI.Color { + /// not dark-lock). S10.7 extends the candidate set with the two high-contrast + /// appearance names, so a token can opt into Increase-Contrast variants (they + /// default to the base values — existing call sites are unchanged). STRUCTURAL + /// increase-contrast responses (thicker hairlines/borders) belong to the + /// `.glassPanel` modifier, not to color tokens. + static func dynamic(light: SwiftUI.Color, dark: SwiftUI.Color, + lightHighContrast: SwiftUI.Color? = nil, + darkHighContrast: SwiftUI.Color? = nil) -> SwiftUI.Color { let lightNS = NSColor(light) let darkNS = NSColor(dark) + let lightHCNS = NSColor(lightHighContrast ?? light) + let darkHCNS = NSColor(darkHighContrast ?? dark) return SwiftUI.Color(nsColor: NSColor(name: nil) { appearance in - appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? darkNS : lightNS + switch appearance.bestMatch(from: [ + .aqua, .darkAqua, .accessibilityHighContrastAqua, .accessibilityHighContrastDarkAqua, + ]) { + case .accessibilityHighContrastDarkAqua: darkHCNS + case .accessibilityHighContrastAqua: lightHCNS + case .darkAqua: darkNS + default: lightNS + } }) } - // Accent — appearance-independent (the teal reads on both light + dark). - static let accent = SwiftUI.Color(red: 0.161, green: 0.714, blue: 0.643) // #29B6A4 - static let accentDeep = SwiftUI.Color(red: 0.078, green: 0.537, blue: 0.478) // #148979 + // S10.7 single-source invariant (design §3.2): every VALUE below lives in + // `DesignTokenKit.Palette` (plain RGBA data — headlessly contrast-audited and + // invariant-tested); this enum keeps the API and re-exports via `from(_:)` + // (DesignSystemGlass.swift). No RGBA literal may exist on this side — the former + // inline literals moved to the Kit byte-identically (PR 1a, zero visual change). + + // Accent — appearance-independent (the teal reads on both light + dark). #29B6A4/#148979. + static let accent = from(Palette.accent) + static let accentDeep = from(Palette.accentDeep) /// Foreground drawn ON the accent (e.g. a play glyph over the teal fill). Appearance- /// independent like `accent` itself — white reads on the teal in both light + dark. - static let onAccent = SwiftUI.Color.white + static let onAccent = from(Palette.onAccent) - /// Alternates (swap into accent to change feel) - static let blue = SwiftUI.Color(red: 0.039, green: 0.518, blue: 1.0) // #0A84FF + /// Alternates (swap into accent to change feel). #0A84FF. + static let blue = from(Palette.blue) - /// Surfaces (elevation stack) — dark = pre-S9-T; light = first pass (gray base, - /// white raised cards, darker inset). - static let window = dynamic(light: SwiftUI.Color(white: 0.93), - dark: SwiftUI.Color(red: 0.118, green: 0.118, blue: 0.118)) // #1E1E1E - static let card = dynamic(light: SwiftUI.Color.white, dark: SwiftUI.Color.white.opacity(0.045)) - static let panel = dynamic(light: SwiftUI.Color.white, dark: SwiftUI.Color.white.opacity(0.06)) - static let hairline = dynamic(light: SwiftUI.Color.black.opacity(0.12), - dark: SwiftUI.Color.white.opacity(0.08)) + /// Surfaces (elevation stack) — dark = pre-S9-T (#1E1E1E window, pre-D10); light = + /// first pass (gray base, white raised cards, darker inset). + static let window = from(Palette.window) + static let card = from(Palette.card) + static let panel = from(Palette.panel) + static let hairline = from(Palette.hairline) // Labels — secondary + tertiary lifted so BOTH clear WCAG AA (≥4.5:1) on the // stricter card/panel surface (not just the window), AND the secondary→tertiary // hierarchy stays perceptible (dark 0.92 > 0.55 > 0.48; light 0.90 > 0.62 > 0.55). - // `labelDisabled` is WCAG-exempt (disabled text). Light values still tune-in-make-run. - static let label = dynamic(light: SwiftUI.Color.black.opacity(0.90), dark: SwiftUI.Color.white.opacity(0.92)) - static let labelSecondary = dynamic(light: SwiftUI.Color.black.opacity(0.62), - dark: SwiftUI.Color.white.opacity(0.55)) - static let labelTertiary = dynamic(light: SwiftUI.Color.black.opacity(0.55), - dark: SwiftUI.Color.white.opacity(0.48)) - static let labelDisabled = dynamic(light: SwiftUI.Color.black.opacity(0.28), - dark: SwiftUI.Color.white.opacity(0.25)) - - /// Status (NEW — warning had no semantic token). System-vibrant; read on both. - static let statusWarning = SwiftUI.Color(red: 1.0, green: 0.623, blue: 0.039) // #FF9F0A + // `labelDisabled` is WCAG-exempt (disabled text). That audit is now a PERMANENT + // test: DesignTokenKitTests/ContrastAuditTests (R4). + static let label = from(Palette.label) + static let labelSecondary = from(Palette.labelSecondary) + static let labelTertiary = from(Palette.labelTertiary) + static let labelDisabled = from(Palette.labelDisabled) + + /// Status FILL/indicator colors (non-text 3:1): `statusWarning` #FF9F0A vibrant orange, + /// `statusError` the macOS-26 palette red (#FF383C/#FF4245). PR 6 (founder "split text + /// vs fill"): TEXT/glyph sites use the darker AA-legible variants below instead — the + /// vivid values are for the meter hot-bar and status dots. + static let statusWarning = from(Palette.statusWarning) + static let statusError = from(Palette.statusError) + /// Status TEXT/glyph variants (AA 4.5:1). Differ from the fill values only in LIGHT + /// (on the deep dark base the vivid values already clear text AA). + static let statusWarningText = from(Palette.statusWarningText) + static let statusErrorText = from(Palette.statusErrorText) /// Row-fill tints for a selectable list row (queue / History): the now-playing row reads - /// stronger than a merely-selected one. Accent-derived, so appearance-independent like - /// `accent`. (Formerly inline `accent.opacity(0.25 / 0.12)` literals at the row.) - static let rowNowPlaying = accent.opacity(0.25) - static let rowSelected = accent.opacity(0.12) + /// stronger than a merely-selected one. Accent-derived (the derivation is asserted by + /// TOK-04), so appearance-independent like `accent`. + static let rowNowPlaying = from(Palette.rowNowPlaying) + static let rowSelected = from(Palette.rowSelected) } // MARK: Typography (semantic scale mapped to Dynamic Type text styles) @@ -81,6 +106,10 @@ enum DesignSystem { /// `.title` carry the style's own weight. (Ad-hoc `Font.system(size:)` call sites elsewhere are a /// separate migration — some are SF Symbols / proportional glyph sizes, not text.) enum Font { + /// The Now Playing hero title (8a: 28/800). Dynamic-Type-mapped `.largeTitle` + /// (~26pt macOS default) at heavy weight — a fixed 28pt would break text scaling + /// and is banned (S10.7 §3.2). Pair with `.heroTitle()` for the dark-only halo. + static let heroTitle = SwiftUI.Font.system(.largeTitle, weight: .heavy) static let displayTitle = SwiftUI.Font.system(.title, weight: .bold) // ~22 static let sectionTitle = SwiftUI.Font.system(.title3, weight: .semibold) // ~15 static let body = SwiftUI.Font.system(.body) // ~13 @@ -113,11 +142,12 @@ enum DesignSystem { // MARK: Gradient enum Gradient { - /// App-mark squircle / play-button fill (subtle teal). + /// App-mark squircle / play-button fill (subtle teal). Stops from the Kit palette + /// (#3FD0BA → #1FA893 → accentDeep) — no RGBA literals on the app side (§3.2). static let iconFill = LinearGradient( gradient: SwiftUI.Gradient(colors: [ - SwiftUI.Color(red: 0.247, green: 0.816, blue: 0.729), // #3FD0BA - SwiftUI.Color(red: 0.122, green: 0.659, blue: 0.576), // #1FA893 + Color.from(Palette.iconFillTop), + Color.from(Palette.iconFillMid), Color.accentDeep, ]), startPoint: .top, @@ -132,11 +162,15 @@ enum DesignSystem { /// `ToolbarView`), window min bumps to 880×640 for the footer transport + two Now Playing /// panes. No traffic-light inset — the native titlebar carries the window buttons in their /// own strip, so the chrome shares the content's left margin. + /// Band/window metrics FORWARD the Kit's `NowPlayingLayout` values (single-source + /// invariant): the §7.1 layout-arithmetic tests derive the min-window budget from the + /// Kit, so the shell must lay out from the same numbers — a local copy here would let + /// the tests keep passing against stale values. enum ShellMetrics { - static let chromeHeight: CGFloat = 60 - static let footerHeight: CGFloat = 64 - static let windowMinWidth: CGFloat = 880 - static let windowMinHeight: CGFloat = 640 + static let chromeHeight = CGFloat(NowPlayingLayout.chromeHeight) + static let footerHeight = CGFloat(NowPlayingLayout.footerHeight) + static let windowMinWidth = CGFloat(NowPlayingLayout.windowMinWidth) + static let windowMinHeight = CGFloat(NowPlayingLayout.windowMinHeight) static let hairline: CGFloat = 0.5 } @@ -188,11 +222,14 @@ enum DesignSystem { static let skipButton: CGFloat = 30 // hit target static let skipSymbol: CGFloat = 15 static let scrubberTrackMinWidth: CGFloat = 120 - static let scrubberTrackHeight: CGFloat = 3 static let scrubberHitHeight: CGFloat = 20 - static let thumbSize: CGFloat = 10 - static let timeLabelWidth: CGFloat = 46 - static let signalSlotWidth: CGFloat = 120 + /// The scrubber's carved groove uses GlassDecor.carvedTrackHeight (5pt, shared with the + /// inspector sliders); the thumb is a 14pt CarvedKnob (PR 6). No local track-height. + static let thumbSize: CGFloat = 14 + /// Re-exported from the Kit so SlotFitTests can assert "88:88" fits headlessly (§7.1). + static let timeLabelWidth: CGFloat = .init(SlotWidths.footerTimeLabel) + /// Re-exported from the Kit (SLOT-03 asserts "Enhanced · 176.4 kHz" + dot fits). + static let signalSlotWidth: CGFloat = .init(SlotWidths.footerSignalSlot) static let regionGapInfoToControls: CGFloat = 20 static let regionGap: CGFloat = 16 // controls→scrubber, scrubber→signal static let tooltipHalfWidth: CGFloat = 22 // half the drag tooltip width, for edge clamping diff --git a/Sources/AdaptiveSound/DesignSystemGlass.swift b/Sources/AdaptiveSound/DesignSystemGlass.swift new file mode 100644 index 0000000..95ed6ce --- /dev/null +++ b/Sources/AdaptiveSound/DesignSystemGlass.swift @@ -0,0 +1,261 @@ +// DesignSystemGlass — the SwiftUI half of the S10.7 token contract (design §3.2), and the +// future home of the `Glass` decoration namespace (roles land staged, each with its first +// consumer). Two jobs live here: +// +// 1. BRIDGE: `DesignTokenKit` holds every color as plain RGBA data (the single source — +// testable, auditable); this file turns that data into SwiftUI values. No RGBA literal +// may exist on this side — a value needed here is a value added to the Kit. +// 2. `.glassPanel(_:in:)`: the ONE place a surface material/fill is painted. Views say +// what a surface IS (`SurfaceRole`); the environment→resolver→paint wiring here decides +// how it looks. The semgrep layer-policy tripwires (PR 1b) hold every other UI file to +// that contract. + +import DesignTokenKit +import SwiftUI + +// MARK: - Kit → SwiftUI color bridging + +extension SwiftUI.Color { + /// A Kit color, constructed in explicit sRGB (both the former `Color(white:)` and + /// `Color(red:green:blue:)` token literals were sRGB, so this one path is exact for both). + init(token: RGBAColor) { + self.init(.sRGB, red: token.red, green: token.green, blue: token.blue, opacity: token.alpha) + } +} + +extension DesignSystem.Color { + /// The re-export path for a Kit pair: appearance-independent pairs bridge to a plain + /// color (matching the pre-Kit literals exactly); appearance-dependent pairs go through + /// the dynamic provider, including the increased-contrast appearance names. + static func from(_ pair: AppearancePair) -> SwiftUI.Color { + let isAppearanceIndependent = pair.light == pair.dark + && pair.lightHighContrast == pair.light && pair.darkHighContrast == pair.dark + if isAppearanceIndependent { return SwiftUI.Color(token: pair.light) } + return dynamic(light: SwiftUI.Color(token: pair.light), + dark: SwiftUI.Color(token: pair.dark), + lightHighContrast: SwiftUI.Color(token: pair.lightHighContrast), + darkHighContrast: SwiftUI.Color(token: pair.darkHighContrast)) + } +} + +// MARK: - .glassPanel(_:in:) + +extension View { + /// Declare a surface's ROLE; this modifier (via the Kit's pure resolver) owns how the + /// role is painted, including accessibility fallbacks. PR 1a ships the `.overlay` role + /// (system Material — native Reduce-Transparency/Increase-Contrast adaptation, design + /// §3.2); the fill roles arrive with their consuming PRs. + func glassPanel(_ role: SurfaceRole, in shape: some InsettableShape) -> some View { + modifier(GlassPanelModifier(role: role, shape: shape)) + } +} + +extension View { + /// The hero-title style (S10.7 PR 4): `heroTitle` font, primary label color, and the 8a + /// teal halo — DARK-ONLY (grammar rule 6: light drops emissive cues). Lives here because + /// this file is the sanctioned appearance reader. + func heroTitle() -> some View { + modifier(HeroTitleModifier()) + } +} + +private struct HeroTitleModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { + content + .font(DesignSystem.Font.heroTitle) + .foregroundStyle(DesignSystem.Color.label) + .shadow(color: colorScheme == .dark ? SwiftUI.Color(token: GlassDecor.heroHaloDark) : .clear, + radius: CGFloat(GlassDecor.heroHaloRadius), + x: 0, + y: CGFloat(GlassDecor.heroHaloOffsetY)) + } +} + +// MARK: - Carved slider visuals (S10.7 PR 5 — the 8a track/knob recipe) + +/// The 8a carved GROOVE (track): a token-filled base with a top inner shade (the inset +/// shadow) and a teal progress fill with a dark-only glow (grammar rule 6). Appearance-aware, +/// so it lives in this sanctioned file. Shared by the inspector `CarvedSlider` AND the footer +/// scrubber (PR 6) — one carved surface, two consumers. Vertically centered in whatever height +/// the caller frames it to; the knob/thumb is the caller's concern. +struct CarvedGroove: View { + /// The teal-filled portion, as a fraction [0, 1] of the track width. + let fillFraction: Double + /// Track (groove) thickness. + var height: CGFloat = .init(GlassDecor.carvedTrackHeight) + /// The progress-fill color (accent for the sliders; the scrubber passes its play/pause/ + /// interrupted state color). + var fillColor: SwiftUI.Color = DesignSystem.Color.accent + /// Whether the fill carries the dark-only teal glow (off when the fill isn't the accent — + /// e.g. the scrubber paused/interrupted — so a teal glow never sits under a grey fill). + var glow: Bool = true + + @Environment(\.colorScheme) private var colorScheme + + private static let innerShade: CGFloat = 2 + + var body: some View { + let dark = colorScheme == .dark + GeometryReader { geo in + let clamped = CGFloat(min(max(fillFraction, 0), 1)) + ZStack(alignment: .leading) { + // Carved base: token fill + a top inner shade (the 8a inset shadow). + Capsule() + .fill(SwiftUI.Color(token: GlassDecor.carvedTrack.value(for: dark ? .dark : .light))) + .overlay(alignment: .top) { + LinearGradient( + colors: [SwiftUI.Color(token: dark ? GlassDecor.carvedShadeDark + : GlassDecor.carvedShadeLight), .clear], + startPoint: .top, endPoint: .bottom + ) + .frame(height: Self.innerShade) + } + .clipShape(Capsule()) + .frame(height: height) + + Capsule() + .fill(fillColor) + .frame(width: geo.size.width * clamped, height: height) + .shadow(color: (dark && glow) ? SwiftUI.Color(token: GlassDecor.sliderGlowDark) : .clear, + radius: 5) + } + .frame(maxHeight: .infinity, alignment: .center) + } + } +} + +/// The 8a carved knob/thumb: a token-filled circle with a bottom inner shade (the physical +/// cue, both appearances). Shared by the slider knob and the footer scrubber's hover thumb. +struct CarvedKnob: View { + var size: CGFloat = .init(GlassDecor.sliderKnobSize) + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let dark = colorScheme == .dark + Circle() + .fill(SwiftUI.Color(token: GlassDecor.knobFill.value(for: dark ? .dark : .light))) + .overlay(alignment: .bottom) { + LinearGradient(colors: [.clear, SwiftUI.Color(token: GlassDecor.knobShade)], + startPoint: .center, endPoint: .bottom) + .clipShape(Circle()) + } + .frame(width: size, height: size) + } +} + +/// The inspector slider's track+knob (S10.7 PR 5). Composes the shared `CarvedGroove` (fill to +/// the knob CENTER, so the teal meets the knob at every position) + a `CarvedKnob` at the +/// knob's inset travel position. `CarvedSlider` (UI/Controls) owns interaction. +struct CarvedTrack: View { + /// Filled fraction in [0, 1]. + let fraction: Double + + private static let knobSize = CGFloat(GlassDecor.sliderKnobSize) + + var body: some View { + GeometryReader { geo in + let usable = max(geo.size.width - Self.knobSize, 0) + let knobX = usable * CGFloat(min(max(fraction, 0), 1)) + // The teal reaches the knob's CENTER at every position (identical to the PR-5 + // fill width `knobX + knobSize/2`, expressed as a fraction of the track width). + let fillFraction = Double((knobX + Self.knobSize / 2) / max(geo.size.width, 1)) + ZStack(alignment: .leading) { + CarvedGroove(fillFraction: fillFraction) + CarvedKnob() + .offset(x: knobX) + .frame(maxHeight: .infinity, alignment: .center) + } + } + .frame(height: Self.knobSize) + } +} + +/// The sanctioned environment→resolver wiring for the glow field (this file is the one +/// place appearance may be read — semgrep rule 4). Renders `content` only when the pure +/// `glowFieldIsVisible` resolver says so (dark + no reduced-transparency request). +struct GlowFieldGate: View { + @ViewBuilder var content: Content + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + + var body: some View { + if glowFieldIsVisible(appearance: colorScheme == .dark ? .dark : .light, + reduceTransparency: reduceTransparency, + increasedContrast: colorSchemeContrast == .increased) { + content + } + } +} + +private struct GlassPanelModifier: ViewModifier { + let role: SurfaceRole + let shape: PanelShape + + // The environment→resolver shim: views never read these flags themselves (semgrep rule 4 + // bans per-view appearance branching; this definition file is the sanctioned reader). + @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + + func body(content: Content) -> some View { + let appearance: TokenAppearance = colorScheme == .dark ? .dark : .light + let resolved = resolveSurface( + role: role, + appearance: appearance, + reduceTransparency: reduceTransparency, + increasedContrast: colorSchemeContrast == .increased + ) + switch resolved { + case .systemMaterial(.ultraThin): + content.background(.ultraThinMaterial, in: shape) + case .systemMaterial(.bar): + content.background(.bar, in: shape) + case let .fill(fill): + decorated(content, fill: fill, appearance: appearance) + } + } + + /// The Regime-B strata (design §3.2): fill (+ dark-only bottom bleed) UNDER the content; + /// top-edge specular rim + full glass hairline as distinct strokes above; a soft deep + /// drop shadow (light = ~half opacity, tighter — grammar rule 4). The hairline token + /// carries real Increase-Contrast variants (the "stronger hairlines under IC" promise); + /// the resolver already handed us an OPAQUE fill under RT/IC. + private func decorated(_ content: Content, fill: RGBAColor, + appearance: TokenAppearance) -> some View { + let increasedContrast = colorSchemeContrast == .increased + let rim = SwiftUI.Color(token: GlassDecor.rim.value(for: appearance)) + let hairline = SwiftUI.Color(token: GlassDecor.glassHairline.value( + for: appearance, increasedContrast: increasedContrast + )) + let shadow = SwiftUI.Color(token: GlassDecor.shadowColor.value(for: appearance)) + let dark = appearance == .dark + return content + .background { + ZStack(alignment: .bottom) { + shape.fill(SwiftUI.Color(token: fill)) + if dark { // bottom light bleed is dark-only (grammar rule 3) + LinearGradient(colors: [.clear, SwiftUI.Color(token: GlassDecor.bleedDark)], + startPoint: .top, endPoint: .bottom) + .frame(height: CGFloat(GlassDecor.bleedHeight)) + .clipShape(shape) + } + } + } + .overlay { // specular top rim: a top-weighted stroke, never a full-perimeter ring + shape.strokeBorder( + LinearGradient(colors: [rim, .clear], startPoint: .top, endPoint: .center), + lineWidth: 1 + ) + } + .overlay { shape.strokeBorder(hairline, lineWidth: 1) } + .shadow(color: shadow, + radius: CGFloat(dark ? GlassDecor.shadowRadiusDark : GlassDecor.shadowRadiusLight), + x: 0, + y: CGFloat(dark ? GlassDecor.shadowOffsetYDark : GlassDecor.shadowOffsetYLight)) + } +} diff --git a/Sources/AdaptiveSound/NowPlayingController.swift b/Sources/AdaptiveSound/NowPlayingController.swift index 8c8dfc6..34c5949 100644 --- a/Sources/AdaptiveSound/NowPlayingController.swift +++ b/Sources/AdaptiveSound/NowPlayingController.swift @@ -186,7 +186,7 @@ final class NowPlayingController { guard let self, let loose = await resolveLooseMetadata?(url) else { return } guard isStillCurrent(token), metaToken == token else { return } meta = ResolvedTrackMeta(artist: loose.artist, album: loose.album, artworkKey: nil) - if let data = loose.artworkData, let image = NSImage(data: data) { + if let data = loose.artworkData, let image = Self.decodedArtwork(from: data) { artworkToken = token artwork = image } @@ -194,6 +194,23 @@ final class NowPlayingController { } } + /// Decode embedded loose-file art CAPPED at the library path's 512px (S10.7 PR-7 review: + /// `NSImage(data:)` deferred a FULL-RESOLUTION decode of e.g. a 3000²+ embedded cover to + /// first draw on the main actor — Control Center, the footer thumb, and the glow sampler + /// all need far less). `CGImageSourceCreateThumbnailAtIndex` decodes at the capped size + /// (subsampling/embedded thumbnails), not full-res-then-shrink. + private static func decodedArtwork(from data: Data, maxPixel: Int = 512) -> NSImage? { + let options = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixel, + kCGImageSourceCreateThumbnailWithTransform: true, + ] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let thumb = CGImageSourceCreateThumbnailAtIndex(source, 0, options) + else { return NSImage(data: data) } // exotic containers: keep the old full decode + return NSImage(cgImage: thumb, size: NSSize(width: thumb.width, height: thumb.height)) + } + /// Async-enrich the current track's metadata + artwork; apply each only if the track is still /// current (a fast skip past a track must not stamp its art onto the next one). private func resolveAndRepush(trackID: Int64, token: String) { @@ -224,7 +241,19 @@ final class NowPlayingController { if let artist = snapshot.artist { info[MPMediaItemPropertyArtist] = artist } if let album = snapshot.album { info[MPMediaItemPropertyAlbumTitle] = album } if let artwork { - info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: artwork.size) { _ in artwork } + // The request handler MUST be nonisolated: MediaPlayer invokes it on ITS OWN + // serial queue ("accessQueue") when serializing artwork (e.g. a Control Center + // render after a device switch). Defined inside this @MainActor class, the + // closure INHERITS MainActor isolation, and Swift 6's dynamic isolation check + // (dispatch_assert_queue) TRAPS off-main — the 2026-07-17 founder crashes + // (EXC_BREAKPOINT, thread "*/accessQueue"). `@Sendable` severs the inference; + // the closure captures only the image value and touches no controller state. + // (NSImage is safely readable cross-thread as long as nobody mutates it — this + // one is a fully-decoded artwork instance owned by the closure.) + let image = artwork + info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { @Sendable _ in + image + } } let center = MPNowPlayingInfoCenter.default() center.nowPlayingInfo = info diff --git a/Sources/AdaptiveSound/UI/Controls/CarvedSlider.swift b/Sources/AdaptiveSound/UI/Controls/CarvedSlider.swift new file mode 100644 index 0000000..3927a80 --- /dev/null +++ b/Sources/AdaptiveSound/UI/Controls/CarvedSlider.swift @@ -0,0 +1,79 @@ +import DesignTokenKit +import SwiftUI + +// MARK: - Carved Slider (S10.7 PR 5 — the shared 8a slider primitive) + +/// The interaction half of the 8a carved slider: LIVE-commit dragging (the value binding +/// updates continuously — gain must be audible while dragging), click-to-set, and FULL +/// keyboard/VoiceOver parity (§5 acceptance: reachable-but-inoperable is a PR failure): +/// focusable with the system focus ring, ←/→ step the value, VoiceOver reads the label + +/// value and supports adjustable increments. Visuals come from `CarvedTrack` (the sanctioned +/// appearance file); native `Slider` cannot render the carved look (`.tint` only), which is +/// what justifies this control existing at all. The footer scrubber (deferred-commit +/// semantics) adopts `CarvedTrack` in PR 6 with its own interaction wrapper. +struct CarvedSlider: View { + @Binding var value: Float + var range: ClosedRange = 0 ... 1 + var step: Float = 0.01 + let accessibilityLabel: String + /// Spoken/read value, e.g. "4.0 decibels" — derived by the caller from the same binding. + var accessibilityValueText: String + + @FocusState private var focused: Bool + + private var fraction: Double { + let span = range.upperBound - range.lowerBound + guard span > 0 else { return 0 } + return Double((value - range.lowerBound) / span) + } + + var body: some View { + GeometryReader { geo in + CarvedTrack(fraction: fraction) + // Fill the GeometryReader (it top-aligns by default) so the contentShape — + // and therefore the drag — covers the FULL 20pt hit target, not just the + // track's own 14pt band. + .frame(width: geo.size.width, height: geo.size.height) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { drag in + // Map the pointer onto the KNOB'S travel ([knob/2, width−knob/2] + // — the same inset `CarvedTrack` renders with), not the raw + // width: this is a LIVE-commit audio control, so mouse-down on + // the knob at either extreme must be value-neutral, never a jump. + let knob = CGFloat(GlassDecor.sliderKnobSize) + set(fraction: (drag.location.x - knob / 2) / max(geo.size.width - knob, 1)) + } + ) + } + .frame(height: 20) // hit target taller than the visual track + .focusable() + .focused($focused) + // A focused slider OWNS its arrows (NSSlider parity): consume the key even at the + // bounds — a bubbled ← at min must not reach whatever the enclosing context binds + // arrows to (PR 6 puts a CarvedTrack scrubber next to the footer transport). + .onKeyPress(.leftArrow) { nudge(-step); return .handled } + .onKeyPress(.rightArrow) { nudge(step); return .handled } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(accessibilityValueText) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: nudge(step) + case .decrement: nudge(-step) + @unknown default: break + } + } + } + + private func set(fraction raw: Double) { + let clamped = Float(min(max(raw, 0), 1)) + let stepped = (range.lowerBound + clamped * (range.upperBound - range.lowerBound)) / step + value = min(max(stepped.rounded() * step, range.lowerBound), range.upperBound) + } + + private func nudge(_ delta: Float) { + value = min(max(value + delta, range.lowerBound), range.upperBound) + } +} diff --git a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift index defd706..aa5e679 100644 --- a/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift +++ b/Sources/AdaptiveSound/UI/Library/LibrarySidebar.swift @@ -75,6 +75,7 @@ struct LibrarySidebar: View { } // A sidebar material so the column reads as a source list now that `.listStyle(.sidebar)` is // gone (the plain ScrollView doesn't imply it). + // nosemgrep: ui-no-adhoc-material TEMP reason="Glass-token adoption = S10.8 sweep" expiry=2026-08-15 .background(.bar) .safeAreaInset(edge: .bottom) { footer } .fileImporter(isPresented: $showFolderImporter, allowedContentTypes: [.folder]) { result in @@ -222,7 +223,7 @@ struct LibrarySidebar: View { if let renameError { Text(renameError) .font(DesignSystem.Font.caption) - .foregroundStyle(.red) + .foregroundStyle(DesignSystem.Color.statusErrorText) .padding(.horizontal, DesignSystem.Spacing.small) } } @@ -361,6 +362,7 @@ private extension LibrarySidebar { .transition(.opacity.combined(with: .move(edge: .top))) } } + // nosemgrep: ui-no-adhoc-material TEMP reason="Glass-token adoption = S10.8 sweep" expiry=2026-08-15 .background(.bar) } } diff --git a/Sources/AdaptiveSound/UI/Loudness/LoudnessMetersView.swift b/Sources/AdaptiveSound/UI/Loudness/LoudnessMetersView.swift index 7dd7bdf..6297e33 100644 --- a/Sources/AdaptiveSound/UI/Loudness/LoudnessMetersView.swift +++ b/Sources/AdaptiveSound/UI/Loudness/LoudnessMetersView.swift @@ -84,7 +84,7 @@ private struct PeakMeterBar: View { ZStack(alignment: .leading) { Capsule().fill(Color.asCard) Capsule() - .fill(isHot ? Color.red : Color.asAccent) + .fill(isHot ? DesignSystem.Color.statusError : Color.asAccent) .frame(width: geo.size.width * CGFloat(fraction)) } } @@ -95,7 +95,7 @@ private struct PeakMeterBar: View { if isHot { Text("CLIP") .font(DesignSystem.Font.micro.weight(.bold)) - .foregroundStyle(Color.red) + .foregroundStyle(DesignSystem.Color.statusErrorText) // text → AA variant (fill above stays vivid) } } .accessibilityElement(children: .ignore) diff --git a/Sources/AdaptiveSound/UI/NowPlaying/ArtworkGlowSampler.swift b/Sources/AdaptiveSound/UI/NowPlaying/ArtworkGlowSampler.swift new file mode 100644 index 0000000..74ea39a --- /dev/null +++ b/Sources/AdaptiveSound/UI/NowPlaying/ArtworkGlowSampler.swift @@ -0,0 +1,87 @@ +import AppKit +import DesignTokenKit +import Observation + +// MARK: - Artwork Glow Sampler (S10.7 PR 7 — founder decision D8) + +/// Extracts the current artwork's dominant colors and publishes the CLAMPED per-slot glow +/// palette `GlowField` renders (design §3.3): downsample → pixel votes → the Kit's +/// `SampledGlow` selection + clamp — every published color is audit-admissible by +/// construction (R4-GLOW-D8 proves the clamp box's corner, so no cover can break contrast). +/// Per-track palette cache (the `ArtworkThumbnailStore` pattern); a `nil` palette — missing +/// art, unreadable art, or the token-guarded resolve gap right after a track change — means +/// the brand colors. +@MainActor +@Observable +final class ArtworkGlowSampler { + /// Per-slot overrides for `GlowFieldSpec.glows`; a nil slot (or nil array) keeps brand. + private(set) var palette: [RGBAColor?]? + + /// Palette cache keyed by track identity; insertion-ordered for cheap FIFO eviction. + private var cache: [String: [RGBAColor?]] = [:] + private var cacheOrder: [String] = [] + private let cacheLimit = 64 + + private static let thumbSide = 24 + + /// Recompute (or recall) the palette for the current track's artwork. Deliberately + /// synchronous: a 24×24 downsample + 576 pixel votes is microseconds — no off-main hop, + /// no Sendable laundering of AppKit types. + func update(artwork: NSImage?, trackKey: String?) { + guard let artwork, let trackKey else { + palette = nil + return + } + if let cached = cache[trackKey] { + palette = cached + return + } + let computed = Self.samplePalette(from: artwork) + cache[trackKey] = computed + cacheOrder.append(trackKey) + if cacheOrder.count > cacheLimit { + cache.removeValue(forKey: cacheOrder.removeFirst()) + } + palette = computed + } + + /// Downsample to a tiny sRGB thumb and run the pure Kit pipeline over its pixels. + private static func samplePalette(from artwork: NSImage) -> [RGBAColor?] { + let side = thumbSide + guard let cgImage = artwork.cgImage(forProposedRect: nil, context: nil, hints: nil), + let context = CGContext( + data: nil, width: side, height: side, + bitsPerComponent: 8, bytesPerRow: side * 4, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { return brandOnly } + context.interpolationQuality = .low + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: side, height: side)) + guard let bytes = context.data else { return brandOnly } + + // PREMULTIPLIED bytes read raw (see RGBAColor.fromPixel's contract): transparent + // margins premultiply toward black and are dropped by the Kit's vote value floor; + // partial alpha scales channels uniformly — hue kept, vote weight reduced. + let buffer = bytes.bindMemory(to: UInt8.self, capacity: side * side * 4) + var samples: [RGBAColor] = [] + samples.reserveCapacity(side * side) + for pixel in 0 ..< (side * side) { + let base = pixel * 4 + samples.append(.fromPixel(red: buffer[base], + green: buffer[base + 1], + blue: buffer[base + 2])) + } + + let dominant = SampledGlow.dominantColors(samples: samples) + return GlowFieldSpec.glows.indices.map { slot in + slot < dominant.count ? SampledGlow.clampedSampledColor(dominant[slot], slot: slot) : nil + } + } + + /// All-slots-brand (used for unreadable artwork so the failure is cached too — retrying + /// a broken image every track revisit would be waste). + private static var brandOnly: [RGBAColor?] { + GlowFieldSpec.glows.indices.map { _ in nil } + } +} diff --git a/Sources/AdaptiveSound/UI/NowPlaying/GlowField.swift b/Sources/AdaptiveSound/UI/NowPlaying/GlowField.swift new file mode 100644 index 0000000..ac39bb8 --- /dev/null +++ b/Sources/AdaptiveSound/UI/NowPlaying/GlowField.swift @@ -0,0 +1,123 @@ +import DesignTokenKit +import SwiftUI + +// MARK: - Glow Field (S10.7 PR 2 — design §3.3) + +/// The ambient content glows behind the Now Playing tab — the light the glass-look panels +/// visibly sit over (Regime C decoration). Rendered ENTIRELY from `GlowFieldSpec` Kit data: +/// three shapes whose falloff is authored in radial-gradient stops reading the SAME +/// `falloffFraction` profile the R4 geometric audit samples (exact-linear, matching the 8a +/// mock's CSS `radial-gradient(closest-side, peak → 0)` — no `.blur`, zero filter passes). +/// +/// Mounted via `.background { }` (never as a layout sibling — a ~760pt ellipse would inflate +/// the tab's ideal size). Decoration contract: hit-transparent, invisible to accessibility, +/// static, dark-appearance-only, and suppressed under Reduce Transparency / Increase +/// Contrast — all via the pure `glowFieldIsVisible` resolver behind `GlowFieldGate` (RES-04). +struct GlowField: View { + /// D8 (PR 7): per-slot sampled-palette overrides — a nil slot (or nil array) keeps the + /// brand token. Every entry is CLAMPED by the Kit (`SampledGlow`), so whatever arrives + /// here is audit-admissible by construction; the render fold and the R4-GLOW-D8 audit + /// fold share the override convention. + var sampledPalette: [RGBAColor?]? + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + // The window base always paints (AppShell's background is the same token; painting it + // here too keeps the field self-contained wherever it's mounted). + DesignSystem.Color.window + .overlay { + GlowFieldGate { + GeometryReader { geo in + ForEach(0 ..< GlowFieldSpec.glows.count, id: \.self) { index in + GlowEllipse(glow: GlowFieldSpec.glows[index], + override: overrideColor(at: index), + container: geo.size) + .position( + x: geo.size.width * GlowFieldSpec.glows[index].unitCenterX, + y: geo.size.height * GlowFieldSpec.glows[index].unitCenterY + ) + } + } + // Seam feather (PR-2 review MAJOR 6, stopgap until PR 6 glasses the + // bands): fade the field out over the top/bottom edge runs so the flat + // chrome/footer never meet a lit pixel across a 0.5pt hairline. + .mask(seamFeatherMask) + // D8 recolor on track change is a DISCRETE restyle: one crossfade, + // gated on Reduce Motion (→ a cut) — never a continuous animation (§3.3). + .animation(reduceMotion ? nil : .easeInOut(duration: 0.6), + value: sampledPalette) + } + } + .clipped() // ellipses deliberately bleed past the edges; never paint outside + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private func overrideColor(at index: Int) -> RGBAColor? { + guard let sampledPalette, index < sampledPalette.count else { return nil } + return sampledPalette[index] + } + + private var seamFeatherMask: some View { + GeometryReader { geo in + let feather = CGFloat(GlowFieldSpec.seamFeather) + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black, location: feather / max(geo.size.height, 1)), + .init(color: .black, location: 1 - feather / max(geo.size.height, 1)), + .init(color: .clear, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } +} + +// MARK: - One glow + +private struct GlowEllipse: View { + let glow: GlowFieldSpec.Glow + /// D8: the clamped sampled color for this slot (carries its own token alpha), or nil + /// for the brand token pair. + let override: RGBAColor? + let container: CGSize + + private var width: CGFloat { + container.width * glow.unitWidth + } + + private var height: CGFloat { + container.height * glow.unitHeight + } + + var body: some View { + // A CIRCULAR gradient scaled into the spec ellipse (PR-2 review BLOCKER 1: a circular + // gradient clipped by a non-circular Ellipse leaves a hard rim on the minor axis — + // scaling the whole circle makes every gradient isoline the spec ellipse instead). + Circle() + .fill(radialFalloff) + .frame(width: width, height: width) + .scaleEffect(x: 1, y: width > 0 ? height / width : 1) + } + + /// The shared exact-linear profile, expressed as gradient stops: peak → + /// `falloffFraction(midStop)` at the mid stop → clear at the edge. + private var radialFalloff: RadialGradient { + let color = override.map { SwiftUI.Color(token: $0) } ?? DesignSystem.Color.from(glow.color) + let midStop = GlowFieldSpec.falloffMidStop + return RadialGradient( + gradient: Gradient(stops: [ + .init(color: color, location: 0), + .init(color: color.opacity(GlowFieldSpec.falloffFraction(at: midStop)), + location: midStop), + .init(color: color.opacity(0), location: 1), + ]), + center: .center, + startRadius: 0, + endRadius: width / 2 + ) + } +} diff --git a/Sources/AdaptiveSound/UI/NowPlaying/HeroBand.swift b/Sources/AdaptiveSound/UI/NowPlaying/HeroBand.swift new file mode 100644 index 0000000..d48a9e8 --- /dev/null +++ b/Sources/AdaptiveSound/UI/NowPlaying/HeroBand.swift @@ -0,0 +1,197 @@ +import DesignTokenKit +import SwiftUI + +// MARK: - Hero Band (S10.7 PR 4 — design §5) + +/// The Now Playing hero: the 8a 28/800 title (dark-only teal halo), artist line, and the +/// signal-path BADGE ROW — the full state mapping the retired `NowPlayingWidget` carried +/// (design §5): `PURE` (monochrome) / `ENHANCED` + pulsing dot / the "(Pure unavailable)" +/// fallback warning / the interrupted state — plus rate/intensity/crossfeed capsules. +/// Decoder/bits live in the inspector's signal-detail line (PR 5 relocation, §5); the +/// SPOKEN summary here deliberately keeps them — one VoiceOver stop for the whole path. +/// +/// First-launch/empty state is DESIGNED, not endured (§5): placeholder title in +/// `labelSecondary`, NO halo, the badge row hidden-but-space-reserved so nothing reflows on +/// first play. Track changes crossfade (Reduce-Motion-gated); the pulse follows the §3.4 +/// mandated conditional-phaseAnimator pattern (never `.repeatForever` + `onAppear`). +struct HeroBand: View { + @Environment(AudioViewModel.self) private var viewModel + @Environment(NowPlayingController.self) private var nowPlaying + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// 8a: badges are capsule-height-consistent at 22pt — scaled with Dynamic Type so the + /// row never clips (`@ScaledMetric`, review m9; a literal 22 clips at large text sizes). + @ScaledMetric(relativeTo: .subheadline) private var badgeHeight = CGFloat(GlassDecor.badgeBaseHeight) + + private var currentTrack: AudioFile? { + guard let index = viewModel.selectedTrackIndex, + index < viewModel.playlist.count else { return nil } + return viewModel.playlist[index] + } + + var body: some View { + let track = currentTrack + VStack(alignment: .leading, spacing: DesignSystem.Spacing.small) { + if let track { + Text(track.name) + .heroTitle() + .lineLimit(1) + .truncationMode(.tail) + .help(track.name) // long-title tooltip (§5) + .contentTransition(.opacity) + Text(nowPlaying.currentArtist ?? "Unknown Artist") + .font(DesignSystem.Font.body) + .foregroundStyle(DesignSystem.Color.labelSecondary) + .lineLimit(1) + .contentTransition(.opacity) + } else { + // Empty state (§5): quiet placeholder, NO halo, same line metrics. + Text("Nothing playing") + .font(DesignSystem.Font.heroTitle) + .foregroundStyle(DesignSystem.Color.labelSecondary) + .lineLimit(1) + Text("Click a track to play") + .font(DesignSystem.Font.body) + .foregroundStyle(DesignSystem.Color.labelTertiary) + .lineLimit(1) + } + + // The badge row reserves its space even when hidden (empty state) so first + // play never reflows the hero (§5). + SignalBadgeRow(info: viewModel.signalPath, + isPlaying: viewModel.isPlaying, + reduceMotion: reduceMotion, + badgeHeight: badgeHeight) + .opacity(track == nil ? 0 : 1) + .accessibilityHidden(track == nil) + } + .frame(maxWidth: .infinity, alignment: .leading) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: track?.id) + } +} + +// MARK: - Signal badge row + +/// The §5 state mapping, as capsule badges. All colors/fills are tokens; the capsules are +/// `.glassPanel(.badge)` fills (Regime B — the resolver owns RT/IC). +private struct SignalBadgeRow: View { + let info: SignalPathInfo + let isPlaying: Bool + let reduceMotion: Bool + let badgeHeight: CGFloat + + var body: some View { + HStack(spacing: DesignSystem.Spacing.small) { + if info.interrupted { + BadgeCapsule(height: badgeHeight) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + .foregroundStyle(DesignSystem.Color.statusWarningText) + Text("Device disconnected") + .badgeText(DesignSystem.Color.label) + } + } else { + pathBadge + BadgeCapsule(height: badgeHeight) { + Text(info.formattedRate).badgeText(DesignSystem.Color.label) + } + // No bits/decoder capsules: relocated to the inspector's signal-detail + // line (§5) — the hero-left's 300pt minimum budget (LAY-01) assumes the + // SHORT badge set. + if info.path == .enhanced, info.intensityLinear > 0 { + BadgeCapsule(height: badgeHeight) { + Text("\(Int((info.intensityLinear * 100).rounded())) %") + .badgeText(DesignSystem.Color.label) + } + } + if info.intensityLinear > 0, let strength = info.crossfeedStrength { + BadgeCapsule(height: badgeHeight) { + Text("XF:\(strength.displayName)") + .badgeText(DesignSystem.Color.label) + } + } + if info.fellBackToEnhanced { + BadgeCapsule(height: badgeHeight) { + Text("Pure unavailable") + .badgeText(DesignSystem.Color.statusWarningText) + } + } + } + } + .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: info.path) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: info.interrupted) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Signal path") + .accessibilityValue(SignalPathAccessibility.value(for: info)) + } + + /// PURE (accent dot, monochrome text) vs ENHANCED (pulsing dot — §3.4 pattern). + private var pathBadge: some View { + BadgeCapsule(height: badgeHeight) { + let pure = info.path == .pure + let dotColor = pure ? DesignSystem.Color.accent + : (info.fellBackToEnhanced ? DesignSystem.Color.statusWarning + : DesignSystem.Color.labelTertiary) + let dot = Circle().fill(dotColor).frame(width: 5, height: 5) + if !pure, pulseIsActive(isPlaying: isPlaying, reduceMotion: reduceMotion) { + // Conditional phaseAnimator (§3.4): unmounting it IS the deterministic stop; + // the banned .repeatForever+onAppear idiom zombie-animates after gating flips. + dot.phaseAnimator([1.0, GlassDecor.pulseDimOpacity]) { view, opacity in + view.opacity(opacity) + } animation: { _ in + .easeInOut(duration: GlassDecor.pulseHalfCycleSeconds) + } + } else { + dot + } + Text(pure ? "PURE" : "ENHANCED") + .badgeText(DesignSystem.Color.label) + .tracking(0.5) + } + } +} + +// MARK: - Badge capsule + +private struct BadgeCapsule: View { + let height: CGFloat + @ViewBuilder var content: Content + + var body: some View { + // Fill + rim + hairline all come from the `.badge` role's strata — no site paint. + HStack(spacing: 5) { content } + .padding(.horizontal, 10) + .frame(height: height) + .glassPanel(.badge, in: Capsule()) + } +} + +private extension Text { + func badgeText(_ color: SwiftUI.Color) -> Text { + font(DesignSystem.Font.micro).foregroundColor(color) + } +} + +// MARK: - Accessibility value (carried over from the retired SignalPathBadge) + +enum SignalPathAccessibility { + static func value(for info: SignalPathInfo) -> String { + if info.interrupted { return "Playback paused, output device disconnected" } + let pathText = info.path == .pure ? "Pure mode" : "Enhanced mode" + let rateText = info.achievedSampleRate > 0 + ? info.formattedRate.replacing(" kHz", with: " kilohertz") + : "unknown rate" + var parts = [pathText, rateText] + if info.bitDepth > 0 { + parts.append("\(info.bitDepth)-bit \(info.isFloat ? "float" : "integer")") + } else if info.isFloat { + parts.append("32-bit float") + } + if info.path == .pure, let decoder = info.decoder { + parts.append(decoder == .apple ? "Apple decoder" : "FFmpeg decoder") + } + var result = parts.joined(separator: ", ") + if info.fellBackToEnhanced { result += " — Pure mode unavailable" } + return result + } +} diff --git a/Sources/AdaptiveSound/UI/NowPlaying/InspectorColumn.swift b/Sources/AdaptiveSound/UI/NowPlaying/InspectorColumn.swift new file mode 100644 index 0000000..dedc5d8 --- /dev/null +++ b/Sources/AdaptiveSound/UI/NowPlaying/InspectorColumn.swift @@ -0,0 +1,192 @@ +import DesignTokenKit +import SwiftUI + +// MARK: - Inspector Column (S10.7 PR 5 — founder decision D2: the 8a trailing glass column) + +/// The fixed-260pt trailing inspector: master gain, Reimagine intensity, the signal detail +/// line (decoder/bit-depth — relocated from the hero badges per §5), loudness meters, and +/// crossfeed. Content scrolls INSIDE the panel chrome (§5 mandated architecture: the panel's +/// rim/hairline must never scroll away with the content), padded BEFORE the fixed frame (the +/// S9 lesson's PR-5 form: pad-after-frame silently widens the column and steals queue width). +/// Tertiary text is allowed here by the §3.3 placement rule (right side — never the teal core). +struct InspectorColumn: View { + @Environment(AudioViewModel.self) private var viewModel + + var body: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: DesignSystem.Spacing.medium) { + MasterGainSliderView() + ReimagineSectionView() + signalDetail + LoudnessMetersView() + HeadphonesSectionView() + } + .padding([.horizontal, .top], 14) + // Bottom inset == the bleed run (break-it R4 catch): the dark-only bottom light + // bleed brightens the panel fill enough that tertiary text RESTING on it fails + // AA (4.18 measured). Reading the same token as the bleed's height means text + // can never rest on the bleed and the two values cannot drift apart. (Rows still + // CROSS the run transiently while scrolling — accepted, same class as text + // passing under the seam feather.) + .padding(.bottom, CGFloat(GlassDecor.bleedHeight)) + } + // Clip the SCROLLING content to the panel's shape: `.glassPanel` paints fill under + // and strokes over but never clips, so rows crossing the top/bottom edge would + // render square into the radius-22 corner cutouts (~6pt intrusion) — and this + // panel is EXPECTED to scroll at the 640pt window minimum. + .clipShape(RoundedRectangle(cornerRadius: CGFloat(GlassDecor.panelRadius), + style: .continuous)) + .glassPanel(.panel, in: RoundedRectangle(cornerRadius: CGFloat(GlassDecor.panelRadius), + style: .continuous)) + .frame(width: CGFloat(NowPlayingLayout.inspectorWidth)) + } + + /// Decoder + bit-depth detail (§5: the audiophile home for what left the hero badges). + @ViewBuilder + private var signalDetail: some View { + let info = viewModel.signalPath + let parts: [String] = [ + info.formattedBits, + info.path == .pure ? (info.decoder == .apple ? "Apple decoder" + : info.decoder == .ffmpeg ? "FFmpeg decoder" : nil) : nil, + ].compactMap(\.self) + if !parts.isEmpty { + Text(parts.joined(separator: " · ")) + .font(DesignSystem.Font.monoSmall) + .foregroundStyle(DesignSystem.Color.labelTertiary) + .accessibilityLabel("Signal detail") + .accessibilityValue(parts.joined(separator: ", ")) + } + } +} + +// MARK: - Reimagine Intensity Section (QW-A) + +/// Horizontal carved slider for the global Reimagine intensity knob. +struct ReimagineSectionView: View { + @Environment(AudioViewModel.self) private var viewModel + + var body: some View { + @Bindable var bvm = viewModel + + let isPureBypassed = bvm.pureModeEngaged + let percent = Int((bvm.intensity * 100).rounded()) + + return VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Intensity") + .font(.caption.weight(.semibold)) + .tracking(0.5) + .textCase(.uppercase) + .foregroundStyle(Color.asLabelSecond) + + Spacer() + + if isPureBypassed { + Text("Pure (bypassed)") + .font(DesignSystem.Font.monoSmall) + .foregroundStyle(Color.asLabelTertiary) + } else { + Text("\(percent) %") + .font(DesignSystem.Font.monoSmall.weight(.semibold)) + .foregroundStyle(Color.asLabelSecond) + } + } + + CarvedSlider(value: $bvm.intensity, + accessibilityLabel: "Reimagine Intensity", + accessibilityValueText: "\(percent) percent") + .disabled(isPureBypassed) + .help(bvm.intensity == 0 ? "0 % = bit-perfect bypass" : "") + + HStack { + Text("Bypass") + .font(DesignSystem.Font.trackSubtitle) + .foregroundStyle(Color.asLabelTertiary) + Spacer() + Text("Full Blend") + .font(DesignSystem.Font.trackSubtitle) + .foregroundStyle(Color.asLabelTertiary) + } + } + .opacity(isPureBypassed ? 0.5 : 1) + } +} + +// MARK: - Headphones Section (QW-C) + +/// Crossfeed toggle + strength picker; disabled and dimmed on non-headphone devices. +struct HeadphonesSectionView: View { + @Environment(AudioViewModel.self) private var viewModel + + var body: some View { + @Bindable var bvm = viewModel + + let isEnabled = bvm.deviceIsHeadphones + + return VStack(alignment: .leading, spacing: 8) { + Text("Headphones") + .font(.caption.weight(.semibold)) + .tracking(0.5) + .textCase(.uppercase) + .foregroundStyle(Color.asLabelSecond) + + if !isEnabled { + Text("Connect headphones to enable. (On a speaker device the only consequence " + + "of crossfeed is a mild, reversible centre-image change — crossfeed is " + + "offered here, not auto-applied.)") + .font(DesignSystem.Font.trackSubtitle) + .foregroundStyle(Color.asLabelTertiary) + .fixedSize(horizontal: false, vertical: true) + } + + // ONE row (founder, PR-5 screenshot round): toggle leading, strength picker + // trailing — the picker joins the row only while crossfeed is on. + HStack(spacing: DesignSystem.Spacing.small) { + CrossfeedToggleRow(crossfeedEnabled: $bvm.crossfeedEnabled, deviceEnabled: isEnabled) + .fixedSize() + Spacer(minLength: 0) + if bvm.crossfeedEnabled && isEnabled { + CrossfeedStrengthPicker(strength: $bvm.crossfeedStrength) + .fixedSize() + } + } + } + .opacity(isEnabled ? 1 : 0.5) + } +} + +// MARK: - Crossfeed Toggle Row + +private struct CrossfeedToggleRow: View { + @Binding var crossfeedEnabled: Bool + let deviceEnabled: Bool + + var body: some View { + Toggle(isOn: $crossfeedEnabled) { + Label("Crossfeed", systemImage: "ear.and.waveform") + .font(DesignSystem.Font.body) + .foregroundStyle(Color.asLabel) + } + .toggleStyle(.switch) + .tint(Color.asAccent) + .disabled(!deviceEnabled) + } +} + +// MARK: - Crossfeed Strength Picker + +private struct CrossfeedStrengthPicker: View { + @Binding var strength: CrossfeedStrength + + var body: some View { + Picker("Strength", selection: $strength) { + ForEach(CrossfeedStrength.allCases) { preset in + Text(preset.displayName).tag(preset) + } + } + .pickerStyle(.menu) + .labelsHidden() + .accessibilityLabel("Crossfeed Strength") + } +} diff --git a/Sources/AdaptiveSound/UI/NowPlaying/LeftPanelView.swift b/Sources/AdaptiveSound/UI/NowPlaying/LeftPanelView.swift deleted file mode 100644 index 247d23e..0000000 --- a/Sources/AdaptiveSound/UI/NowPlaying/LeftPanelView.swift +++ /dev/null @@ -1,56 +0,0 @@ -import SwiftUI - -// MARK: - Left Panel - -struct LeftPanelView: View { - @Environment(AudioViewModel.self) var viewModel - - /// Semantic layout constants — adjust once, affects every section. - private enum Layout { - /// "Artistic" breathing room from the window's left edge. - static let leadingPad: CGFloat = 28 - static let trailingPad: CGFloat = 20 - /// Consistent vertical rhythm between all sections. - static let sectionVPad: CGFloat = 14 - /// The spectrum header warrants slightly more top air. - static let spectrumVPad: CGFloat = 16 - } - - var body: some View { - // Scrolls within its pane (L4b): the Now Playing detail stack (spectrum → gain → track - // info) can be taller than the shell's bounded content region on a short window, so it - // must scroll rather than be clipped. On a tall window it simply sits top-aligned. - // Transport (scrubber + play controls) moved to the global footer bar in L3. - ScrollView(.vertical) { - VStack(spacing: 0) { - SpectrumAnalyzerView() - .frame(height: 50) - .padding(.leading, Layout.leadingPad) - .padding(.trailing, Layout.trailingPad) - .padding(.vertical, Layout.spectrumVPad) - // Double-click the spectrum to open the dedicated Monitoring tab (per-channel - // before/after). Single-tap is unaffected (the spectrum has no single-tap action). - .contentShape(Rectangle()) - .onTapGesture(count: 2) { viewModel.selectedTab = .monitoring } - .help("Double-click to open Monitoring") - - MasterGainSliderView() - .padding(.leading, Layout.leadingPad) - .padding(.trailing, Layout.trailingPad) - .padding(.vertical, Layout.sectionVPad) - - // Divider() does not respond to foregroundStyle on macOS — - // a filled Rectangle is the only reliable way to honour the - // hairline design token. - Rectangle() - .fill(Color.asHairline) - .frame(height: 0.5) - - NowPlayingInfoView() - .padding(.leading, Layout.leadingPad) - .padding(.trailing, Layout.trailingPad) - .padding(.vertical, Layout.sectionVPad) - } - } - } -} diff --git a/Sources/AdaptiveSound/UI/NowPlaying/MasterGainSliderView.swift b/Sources/AdaptiveSound/UI/NowPlaying/MasterGainSliderView.swift index 2866b8d..2dbbb37 100644 --- a/Sources/AdaptiveSound/UI/NowPlaying/MasterGainSliderView.swift +++ b/Sources/AdaptiveSound/UI/NowPlaying/MasterGainSliderView.swift @@ -24,12 +24,11 @@ struct MasterGainSliderView: View { .foregroundStyle(Color.asLabelSecond) } - Slider(value: $vm.masterGain, in: 0 ... 1, step: 0.01) - .tint(Color.asAccent) - .accessibilityLabel("Master Gain") - .accessibilityValue( - "\(Double(vm.masterGain) * 20 - 10, format: .number.precision(.fractionLength(1))) decibels" - ) + CarvedSlider( + value: $vm.masterGain, + accessibilityLabel: "Master Gain", + accessibilityValueText: String(format: "%.1f decibels", Double(vm.masterGain) * 20 - 10) + ) } } } diff --git a/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingInfoView.swift b/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingInfoView.swift deleted file mode 100644 index de35afb..0000000 --- a/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingInfoView.swift +++ /dev/null @@ -1,152 +0,0 @@ -import SwiftUI - -// MARK: - Now Playing Info - -/// The now-playing card, live loudness meters, Reimagine intensity knob (QW-A), and headphone -/// crossfeed controls (QW-C). Lives in the Now Playing left panel beneath the spectrum + -/// master-gain controls. (Transport + seek moved to the global footer bar in L3.) -struct NowPlayingInfoView: View { - var body: some View { - VStack(alignment: .leading, spacing: 16) { - NowPlayingWidget() - LoudnessMetersView() - ReimagineSectionView() - HeadphonesSectionView() - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -// MARK: - Reimagine Intensity Section - -/// Horizontal slider for the global Reimagine intensity knob (QW-A). -/// Matches the `MasterGainSliderView` layout pattern. -private struct ReimagineSectionView: View { - @Environment(AudioViewModel.self) private var viewModel - - var body: some View { - @Bindable var bvm = viewModel - - let isPureBypassed = bvm.pureModeEngaged - let percentText = Text( - "\(Int((bvm.intensity * 100).rounded())) %" - ) - .font(DesignSystem.Font.monoSmall.weight(.semibold)) - .foregroundStyle(isPureBypassed ? Color.asLabelTertiary : Color.asLabelSecond) - - return VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Intensity") - .font(.caption.weight(.semibold)) - .tracking(0.5) - .textCase(.uppercase) - .foregroundStyle(Color.asLabelSecond) - - Spacer() - - if isPureBypassed { - Text("Pure (bypassed)") - .font(DesignSystem.Font.monoSmall) - .foregroundStyle(Color.asLabelTertiary) - } else { - percentText - } - } - - Slider(value: $bvm.intensity, in: 0 ... 1, step: 0.01) - .tint(Color.asAccent) - .disabled(isPureBypassed) - .help(bvm.intensity == 0 ? "0 % = bit-perfect bypass" : "") - .accessibilityLabel("Reimagine Intensity") - .accessibilityValue("\(Int((bvm.intensity * 100).rounded())) percent") - - HStack { - Text("Bypass") - .font(DesignSystem.Font.trackSubtitle) - .foregroundStyle(Color.asLabelTertiary) - Spacer() - Text("Full Blend") - .font(DesignSystem.Font.trackSubtitle) - .foregroundStyle(Color.asLabelTertiary) - } - } - .opacity(isPureBypassed ? 0.5 : 1) - } -} - -// MARK: - Headphones Section - -/// Crossfeed toggle + strength picker shown when crossfeed is enabled (QW-C). -/// Disabled and dimmed on non-headphone devices (wireless / USB heuristic). -private struct HeadphonesSectionView: View { - @Environment(AudioViewModel.self) private var viewModel - - var body: some View { - @Bindable var bvm = viewModel - - let isEnabled = bvm.deviceIsHeadphones - - return VStack(alignment: .leading, spacing: 8) { - Text("Headphones") - .font(.caption.weight(.semibold)) - .tracking(0.5) - .textCase(.uppercase) - .foregroundStyle(Color.asLabelSecond) - - if !isEnabled { - Text("Connect headphones to enable. (On a speaker device the only consequence " - + "of crossfeed is a mild, reversible centre-image change — crossfeed is " - + "offered here, not auto-applied.)") - .font(DesignSystem.Font.trackSubtitle) - .foregroundStyle(Color.asLabelTertiary) - .fixedSize(horizontal: false, vertical: true) - } - - HStack(spacing: 12) { - CrossfeedToggleRow(crossfeedEnabled: $bvm.crossfeedEnabled, deviceEnabled: isEnabled) - .fixedSize() - Spacer(minLength: 12) - if bvm.crossfeedEnabled && isEnabled { - CrossfeedStrengthPicker(strength: $bvm.crossfeedStrength) - .fixedSize() - } - } - } - .opacity(isEnabled ? 1 : 0.5) - } -} - -// MARK: - Crossfeed Toggle Row - -private struct CrossfeedToggleRow: View { - @Binding var crossfeedEnabled: Bool - let deviceEnabled: Bool - - var body: some View { - Toggle(isOn: $crossfeedEnabled) { - Label("Crossfeed", systemImage: "ear.and.waveform") - .font(DesignSystem.Font.body) - .foregroundStyle(Color.asLabel) - } - .toggleStyle(.switch) - .tint(Color.asAccent) - .disabled(!deviceEnabled) - } -} - -// MARK: - Crossfeed Strength Picker - -private struct CrossfeedStrengthPicker: View { - @Binding var strength: CrossfeedStrength - - var body: some View { - Picker("Strength", selection: $strength) { - ForEach(CrossfeedStrength.allCases) { preset in - Text(preset.displayName).tag(preset) - } - } - .pickerStyle(.menu) - .labelsHidden() - .accessibilityLabel("Crossfeed Strength") - } -} diff --git a/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingTabView.swift b/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingTabView.swift index 7f2538f..08fe8af 100644 --- a/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingTabView.swift +++ b/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingTabView.swift @@ -1,28 +1,115 @@ +import DesignTokenKit import SwiftUI -// MARK: - Now Playing Tab View +// MARK: - Now Playing Tab View (S10.7 PR 5 — the 8a restructure) +/// The 8a layout (design §5): a hero row (title/artist/badges left, the analyzer LENS right, +/// vertically centered against each other) over a queue-flex + fixed-260 inspector split. +/// The ambient glow field paints behind everything. +/// +/// SCROLLING ARCHITECTURE (§5, mandated): NO outer ScrollView — an unbounded height proposal +/// would materialize every queue row (virtualization dead) and break jump-to-now-playing. +/// The hero hugs its intrinsic height; the queue scrolls internally; the inspector scrolls +/// its content inside its panel chrome. Near the 880×640 minimum the inspector is EXPECTED +/// to scroll — that's the design, not a defect. struct NowPlayingTabView: View { - @Environment(AudioViewModel.self) var viewModel + @Environment(AudioViewModel.self) private var viewModel + @Environment(NowPlayingController.self) private var nowPlaying + /// D8 (PR 7): the sampler owns the per-track clamped palette the field renders. + @State private var glowSampler = ArtworkGlowSampler() var body: some View { - HStack(spacing: 0) { - LeftPanelView() - .containerRelativeFrame(.horizontal, count: 2, span: 1, spacing: 0) - - RightPanelView() - // Pad BEFORE constraining to half-width so the inset stays inside - // the panel (padding after containerRelativeFrame overflowed the - // window edge and clipped the right-aligned LUFS readouts). - .padding(16) - .containerRelativeFrame(.horizontal, count: 2, span: 1, spacing: 0) - .background(Color.asCard) - .overlay(alignment: .leading) { - Rectangle() - .fill(Color.asHairline) - .frame(width: 0.5) - } + VStack(spacing: 0) { + HeroRow() + .padding(.horizontal, CGFloat(NowPlayingLayout.contentInset)) + .padding(.top, CGFloat(NowPlayingLayout.contentInset)) + .padding(.bottom, 12) + + HStack(alignment: .top, spacing: CGFloat(NowPlayingLayout.regionGap)) { + PlaylistView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + + InspectorColumn() + } + .padding(.horizontal, CGFloat(NowPlayingLayout.contentInset)) + .padding(.bottom, CGFloat(NowPlayingLayout.contentInset)) } - .background(Color.asWindow) + // The ambient glow field (PR 2): window base + the three 8a glows behind the whole + // tab — art-tinted per D8 (PR 7). Other tabs keep the plain base until S10.8 (D1). + .background { GlowField(sampledPalette: glowSampler.palette) } + // D8: re-sample when the artwork resolves. `currentArtwork` is token-guarded — it + // reads nil during the resolve gap right after a track change, which resets the + // field to brand until the new art lands (the designed fallback, §3.3). `initial: + // true` covers mounting the tab with a track already playing (cache makes it cheap). + .onChange(of: nowPlaying.currentArtwork, initial: true) { _, artwork in + glowSampler.update(artwork: artwork, trackKey: currentTrackKey) + } + } + + /// Cache identity for the current track (same file ⇒ same art ⇒ same palette). Keyed by + /// the file's absolute URL (`AudioFile.id`) — NEVER `relativePath`, which the queue + /// adapter leaves empty for every track (review BLOCKER: one shared "" key served the + /// first album's palette to the whole session). + private var currentTrackKey: String? { + guard let index = viewModel.selectedTrackIndex, + index < viewModel.playlist.count else { return nil } + return viewModel.playlist[index].id.absoluteString + } +} + +// MARK: - Hero Row + +/// Hero-left (title/artist/badges) + the lens (D6: flexing 400→560 × 122), CENTERED against +/// each other (§5: no lower-left void — the transport-less hero's composition). Beyond the +/// lens's max width, whitespace between the two is legitimate hero negative space. +private struct HeroRow: View { + @Environment(AudioViewModel.self) private var viewModel + @FocusState private var lensFocused: Bool + @State private var lensHovered = false + + var body: some View { + HStack(alignment: .center, spacing: CGFloat(NowPlayingLayout.regionGap)) { + HeroBand() + .frame(maxWidth: .infinity, alignment: .leading) + + lens + } + } + + /// The analyzer lens carries the double-click→Monitoring affordance (§3.4): hover rim + /// brighten + pointer, keyboard focus + Return, and an exposed accessibility element + /// with a named action — the affordance reaches everyone, not just sighted pointer + /// users (the fool's replacement of the old "tab picker exists" position). + private var lens: some View { + SpectrumAnalyzerView() + .frame(minWidth: CGFloat(NowPlayingLayout.lensMinWidth), + maxWidth: CGFloat(NowPlayingLayout.lensMaxWidth), + minHeight: CGFloat(NowPlayingLayout.lensHeight), + maxHeight: CGFloat(NowPlayingLayout.lensHeight)) + .overlay { + if lensHovered || lensFocused { + RoundedRectangle(cornerRadius: CGFloat(GlassDecor.lensRadius), style: .continuous) + .strokeBorder(DesignSystem.Color.accent.opacity(0.35), lineWidth: 1) + } + } + .contentShape(RoundedRectangle(cornerRadius: CGFloat(GlassDecor.lensRadius), + style: .continuous)) + .onTapGesture(count: 2) { viewModel.selectedTab = .monitoring } + .onHover { lensHovered = $0 } + .focusable() + .focused($lensFocused) + // Return only — NOT Space: the transport's Space key-equivalent (Controls menu) + // is matched before any focused view's `onKeyPress`, so a Space handler here + // would be dead code (the same audit that removed the queue list's). + .onKeyPress(.return) { + viewModel.selectedTab = .monitoring + return .handled + } + .help("Double-click to open Monitoring") + .accessibilityElement(children: .ignore) + .accessibilityLabel("Spectrum analyzer") + // Default action (VO-press works directly) + the named action in the rotor. + .accessibilityAction { viewModel.selectedTab = .monitoring } + .accessibilityAction(named: "Open Monitoring") { viewModel.selectedTab = .monitoring } } } diff --git a/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingWidget.swift b/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingWidget.swift deleted file mode 100644 index 53cba10..0000000 --- a/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingWidget.swift +++ /dev/null @@ -1,268 +0,0 @@ -import SwiftUI - -// MARK: - Now Playing Widget - -/// Compact card showing the current track's artwork placeholder, name, and live signal-path badge. -/// The progress/seek row now lives in the global footer transport (`NowPlayingBar`, L3). -struct NowPlayingWidget: View { - @Environment(AudioViewModel.self) var viewModel - - var body: some View { - if let selectedIndex = viewModel.selectedTrackIndex, - selectedIndex < viewModel.playlist.count { - let currentTrack = viewModel.playlist[selectedIndex] - TrackCard(track: currentTrack) - } else { - EmptyTrackCard() - } - } -} - -// MARK: - Track Card - -private struct TrackCard: View { - @Environment(AudioViewModel.self) private var viewModel - // S10.4 D2: the current track's resolved artist/artwork (nil until resolved / for loose files). - @Environment(NowPlayingController.self) private var nowPlaying - let track: AudioFile - - var body: some View { - VStack(spacing: 12) { - HStack(spacing: 12) { - artwork - - VStack(alignment: .leading, spacing: 4) { - Text(track.name) - .font(DesignSystem.Font.trackTitle) - .foregroundStyle(Color.asLabel) - .lineLimit(1) - - Text(nowPlaying.currentArtist ?? "Unknown Artist") - .font(DesignSystem.Font.caption) - .foregroundStyle(Color.asLabelSecond) - .lineLimit(1) - } - - Spacer() - } - - SignalPathBadge(info: viewModel.signalPath) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(12) - .background(Color.asWindow) - .clipShape(.rect(cornerRadius: 8)) - } - - /// Real cover when resolved (S10.4 D2); the music.note placeholder otherwise. - private var artwork: some View { - Group { - if let image = nowPlaying.currentArtwork { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - } else { - Image(systemName: "music.note") - .font(.system(size: 24)) - .foregroundStyle(Color.asAccent) - .frame(width: 52, height: 52) - .background(Color.asWindow) - } - } - .frame(width: 52, height: 52) - .clipShape(.rect(cornerRadius: 8)) - } -} - -// MARK: - Empty Track Card - -private struct EmptyTrackCard: View { - var body: some View { - VStack(spacing: 12) { - HStack(spacing: 12) { - Image(systemName: "music.note") - .font(.system(size: 24)) - .foregroundStyle(Color.asLabelTertiary) - .frame(width: 52, height: 52) - .background(Color.asCard) - .clipShape(.rect(cornerRadius: 8)) - - VStack(alignment: .leading, spacing: 4) { - Text("No track selected") - .font(DesignSystem.Font.trackTitle) - .foregroundStyle(Color.asLabelSecond) - .lineLimit(1) - - Text("Click a track to play") - .font(DesignSystem.Font.caption) - .foregroundStyle(Color.asLabelTertiary) - .lineLimit(1) - } - - Spacer() - } - } - .padding(12) - .background(Color.asWindow) - .clipShape(.rect(cornerRadius: 8)) - } -} - -// MARK: - Signal Path Badge - -/// Inline badge summarising the live signal path. Shows dot + formatted string -/// (or an interrupted warning). Private to this file — consumed only by `TrackCard`. -private struct SignalPathBadge: View { - let info: SignalPathInfo - - var body: some View { - HStack(spacing: 5) { - if info.interrupted { - interruptedContent - } else { - normalContent - } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Signal path") - .accessibilityValue(accessibilityValue) - } - - // MARK: Interrupted state - - private var interruptedContent: some View { - Group { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 10)) - .foregroundStyle(DesignSystem.Color.statusWarning) - Text("Device disconnected") - .font(DesignSystem.Font.trackSubtitle.weight(.medium)) - .foregroundStyle(Color.asLabelSecond) - } - } - - // MARK: Normal state - - private var normalContent: some View { - Group { - Circle() - .fill(dotColor) - .frame(width: 6, height: 6) - - badgeText - } - } - - // MARK: Dot color - - private var dotColor: Color { - if info.fellBackToEnhanced || info.interrupted { - return DesignSystem.Color.statusWarning - } - if info.path == .pure { - return Color.asAccent - } - return Color.asLabelTertiary - } - - // MARK: Badge text (path · rate · bits [· decoder]) - - @ViewBuilder - private var badgeText: some View { - let segments = buildSegments() - - // Render interleaved segments with "·" separators in asLabelTertiary - HStack(spacing: 2) { - ForEach(Array(segments.enumerated()), id: \.offset) { idx, segment in - if idx > 0 { - Text("·") - .font(DesignSystem.Font.trackSubtitle.weight(.medium)) - .foregroundStyle(Color.asLabelTertiary) - } - Text(segment.text) - .font(DesignSystem.Font.trackSubtitle.weight(.medium)) - .foregroundStyle(segment.color) - } - - if info.fellBackToEnhanced { - Text("·") - .font(DesignSystem.Font.trackSubtitle.weight(.medium)) - .foregroundStyle(Color.asLabelTertiary) - Text("(Pure unavailable)") - .font(DesignSystem.Font.trackSubtitle.weight(.medium)) - .foregroundStyle(Color.asLabelTertiary) - } - } - } - - private struct BadgeSegment { - let text: String - let color: Color - } - - private func buildSegments() -> [BadgeSegment] { - var segments: [BadgeSegment] = [] - - // 1. Path - let pathText = info.path == .pure ? "Pure" : "Enhanced" - segments.append(.init(text: pathText, color: Color.asLabelSecond)) - - // 2. Sample rate — shared formatter in SignalPathInfo extension - segments.append(.init(text: info.formattedRate, color: Color.asLabelSecond)) - - // 3. Bit depth / format — only when informative - if let bitsText = info.formattedBits { - segments.append(.init(text: bitsText, color: Color.asLabelSecond)) - } - - // 4. Decoder — Pure path only - if info.path == .pure, let dec = info.decoder { - let decText = dec == .apple ? "Apple" : "FFmpeg" - segments.append(.init(text: decText, color: Color.asLabelSecond)) - } - - // 5. Reimagine intensity — Enhanced path only, when > 0 - if info.path == .enhanced, info.intensityLinear > 0 { - let pct = Int((info.intensityLinear * 100).rounded()) - segments.append(.init(text: "\(pct)%", color: Color.asLabelSecond)) - } - - // 6. Crossfeed badge — only when intensity > 0 (§9: don't show inaudible-chain badge) - if info.intensityLinear > 0, let xfStrength = info.crossfeedStrength { - segments.append(.init(text: "XF:\(xfStrength.displayName)", color: Color.asLabelSecond)) - } - - return segments - } - - // MARK: Accessibility value - - private var accessibilityValue: String { - if info.interrupted { - return "Playback paused, output device disconnected" - } - - let pathStr = info.path == .pure ? "Pure mode" : "Enhanced mode" - let rateVal = info.achievedSampleRate > 0 - ? info.formattedRate.replacing(" kHz", with: " kilohertz") - : "unknown rate" - var parts = [pathStr, rateVal] - - if info.bitDepth > 0 { - let kind = info.isFloat ? "float" : "integer" - parts.append("\(info.bitDepth)-bit \(kind)") - } else if info.isFloat { - parts.append("32-bit float") - } - - if info.path == .pure, let dec = info.decoder { - parts.append(dec == .apple ? "Apple decoder" : "FFmpeg decoder") - } - - var result = parts.joined(separator: ", ") - if info.fellBackToEnhanced { - result += " — Pure mode unavailable" - } - return result - } -} diff --git a/Sources/AdaptiveSound/UI/NowPlaying/RightPanelView.swift b/Sources/AdaptiveSound/UI/NowPlaying/RightPanelView.swift deleted file mode 100644 index 3394ccf..0000000 --- a/Sources/AdaptiveSound/UI/NowPlaying/RightPanelView.swift +++ /dev/null @@ -1,12 +0,0 @@ -import SwiftUI - -// MARK: - Right Panel - -/// The right side of the Now Playing tab: the play queue (header, queue controls, and the -/// scrolling track list) or an empty-queue state. -struct RightPanelView: View { - var body: some View { - PlaylistView() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } -} diff --git a/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift b/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift index 5f2fb93..8f6c138 100644 --- a/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistDetailView.swift @@ -254,6 +254,7 @@ struct PlaylistDetailView: View { Menu { missingRowActions(row) } label: { + // nosemgrep: ui-no-color-literal TEMP reason="→ statusWarningText not fill, S10.8" expiry=2026-08-15 Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) } .menuStyle(.borderlessButton) @@ -287,7 +288,10 @@ struct PlaylistDetailView: View { } .padding(.horizontal, DesignSystem.Spacing.medium) .padding(.vertical, DesignSystem.Spacing.small) - .background(.bar, in: Capsule()) + // Regime A/`.overlay` (S10.7 §3.1): a restore-toast floating over the list. The + // `.bar` substrate preserves this pill's shipped look exactly (PR 1a is + // zero-visual-change); S10.8 may unify it onto `.ultraThin`. + .glassPanel(.overlay(.bar), in: Capsule()) .overlay(Capsule().stroke(DesignSystem.Color.hairline, lineWidth: 0.5)) .padding(.bottom, DesignSystem.Spacing.large) .transition(.opacity) diff --git a/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift b/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift index 134c6a3..9c99148 100644 --- a/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift @@ -1,3 +1,4 @@ +import LibraryBrowseKit import SwiftUI // MARK: - Queue View (the current playback queue — S9 IA change) @@ -5,19 +6,32 @@ import SwiftUI /// The Now Playing queue: ONLY the current play queue, built by the Library's Play / Play Next / /// Add to Queue verbs. Folder-loading moved to the Library section (design §4/§5), so there is no /// folder chooser or "~/…" chip here anymore, and choosing a folder never rewrites this list. +/// +/// S10.7 PR 5 (founder decision D7): a VIEW-LOCAL filter field — narrows the visible rows by +/// TITLE (reusing `FacetTextFilter`, the S9.6 primitive; path was a dead candidate — see +/// `filteredIndices`), never mutates the queue or playback, suppresses the transport Space +/// accelerator while focused, Escape clears, and Jump-to-Now-Playing clears it first rather +/// than silently failing (§5). struct PlaylistView: View { @Environment(AudioViewModel.self) var viewModel - /// Bumped by the header's "Jump to Now Playing" button; observed by the list to scroll the - /// current track into view (UI-2). A monotonic request-ID (not a Bool) so repeated presses - /// re-fire even when the value would otherwise be unchanged. + /// Observed by the list to scroll the current track into view (UI-2). A monotonic + /// request-ID (not a Bool) so repeated presses re-fire even when the value would + /// otherwise be unchanged. Bumped ONLY via `jumpToNowPlaying()` so an active filter is + /// cleared before the list is asked to scroll. @State private var jumpToCurrentRequestID = 0 /// Up Next (the live queue) vs. History (this session's plays). Local view state — the panel /// simply switches which list it shows (S10.2 3a). @State private var panelMode: QueuePanelMode = .upNext + /// The D7 filter — view-local; empty means "filter off". + @State private var filterText = "" + @FocusState private var filterFocused: Bool + /// Key-command focus for the queue list — owned HERE (not by the list) so the filter + /// field's Escape can hand focus back to the queue (§5: ↑/↓ must work immediately). + @FocusState private var queueFocused: Bool var body: some View { VStack(spacing: 12) { - PlaylistHeaderView(jumpToCurrentRequestID: $jumpToCurrentRequestID, panelMode: $panelMode) + PlaylistHeaderView(onJumpToNowPlaying: jumpToNowPlaying, panelMode: $panelMode) Picker("View", selection: $panelMode) { ForEach(QueuePanelMode.allCases) { mode in Text(mode.pickerLabel).tag(mode) @@ -31,7 +45,15 @@ struct PlaylistView: View { if viewModel.queue.isEmpty { emptyQueue } else { - PlaylistItemList(jumpToCurrentRequestID: $jumpToCurrentRequestID) + filterField + if filteredIndices.isEmpty { + noMatches + } else { + PlaylistItemList(jumpToCurrentRequestID: jumpToCurrentRequestID, + visibleIndices: filteredIndices, + reorderEnabled: !filterActive, + queueFocused: $queueFocused) + } } case .history: QueueHistoryList() @@ -39,6 +61,88 @@ struct PlaylistView: View { } } + /// Jump-to-now-playing IGNORES an active filter (§5) — sequenced, not simultaneous: + /// clear the filter in THIS transaction (the full list mounts, every row id registered), + /// then bump the request-ID on the NEXT main-actor turn so the list's `onChange` + + /// `scrollTo` run against the full list. Bumping in the same transaction targeted the + /// FILTERED tree — a filtered-out (or No-Matches-unmounted) target row never scrolled, + /// and a matching row centered against the wrong (still-narrowed) layout. + private func jumpToNowPlaying() { + filterText = "" + Task { @MainActor in + jumpToCurrentRequestID += 1 + } + } + + /// Escape's landing (§5): clear, dismiss the field, and hand key focus to the queue so + /// ↑/↓/Return work immediately — focus must never strand on a defocused field. + private func clearFilterAndFocusQueue() { + filterText = "" + filterFocused = false + queueFocused = true + } + + private var filterActive: Bool { + !filterText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// The visible queue positions under the filter (REAL indices — play/remove/menu actions + /// keep operating on the true queue positions). Matches on the display TITLE only: + /// `relativePath` is empty for library-queued tracks (the queue adapter never fills it — + /// break-it MINOR-5), so it was a dead candidate; artist isn't carried by `AudioFile`. + private var filteredIndices: [Int] { + guard filterActive else { return Array(viewModel.queue.indices) } + return viewModel.queue.indices.filter { index in + FacetTextFilter.matches(viewModel.queue[index].file.name, query: filterText) + } + } + + private var filterField: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11)) + .foregroundStyle(Color.asLabelTertiary) + TextField("Filter queue", text: $filterText) + .textFieldStyle(.plain) + .font(DesignSystem.Font.caption) + .suppressesTransportSpace(while: $filterFocused) + // `.onExitCommand` is the documented macOS cancel hook — the field editor's + // `cancelOperation` can consume Escape before `.onKeyPress` ever sees it. + // The key-press handler stays as belt-and-braces for paths where it does + // fire (it only fires focused, so no guard). + .onExitCommand(perform: clearFilterAndFocusQueue) + .onKeyPress(.escape) { + clearFilterAndFocusQueue() + return .handled + } + if filterActive { + Button("Clear", systemImage: "xmark.circle.fill") { + filterText = "" + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .font(.system(size: 11)) + .foregroundStyle(Color.asLabelTertiary) + .help("Clear the filter") + } + } + .padding(.horizontal, 10) + .frame(height: 26) + .glassPanel(.badge, in: Capsule()) + .accessibilityLabel("Filter queue") + } + + private var noMatches: some View { + ContentUnavailableView { + Label("No Matches", systemImage: "magnifyingglass") + } description: { + Text("No queued track matches “\(filterText)”.") + } actions: { + Button("Clear Filter") { filterText = "" } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + /// Shown whenever the queue is empty (fresh launch, or after Clear Queue). The queue is now /// filled from the Library, so the primary action is a doorway to it (design §4). private var emptyQueue: some View { @@ -60,7 +164,9 @@ struct PlaylistView: View { private struct PlaylistHeaderView: View { @Environment(AudioViewModel.self) var viewModel @Environment(LibraryBrowseModel.self) var library - @Binding var jumpToCurrentRequestID: Int + /// `PlaylistView.jumpToNowPlaying()` — the owner sequences filter-clear before the + /// scroll request (a raw binding-bump here raced the clear; review BLOCKER-1). + let onJumpToNowPlaying: () -> Void @Binding var panelMode: QueuePanelMode /// Mode-aware subtitle: the queue's track count, or the number of recently-played tracks. @@ -91,7 +197,7 @@ private struct PlaylistHeaderView: View { Spacer() - PlaylistControlsView(jumpToCurrentRequestID: $jumpToCurrentRequestID, panelMode: $panelMode) + PlaylistControlsView(onJumpToNowPlaying: onJumpToNowPlaying, panelMode: $panelMode) } } } @@ -100,7 +206,7 @@ private struct PlaylistHeaderView: View { private struct PlaylistControlsView: View { @Environment(AudioViewModel.self) var viewModel - @Binding var jumpToCurrentRequestID: Int + let onJumpToNowPlaying: () -> Void @Binding var panelMode: QueuePanelMode var body: some View { @@ -141,17 +247,15 @@ private struct PlaylistControlsView: View { .accessibilityLabel("Repeat mode: \(["off", "all", "one"][viewModel.repeatMode])") .help(["Off", "All", "One"][viewModel.repeatMode]) - // Jump to now-playing + // Jump to now-playing — the owner's sequenced action (clear filter, THEN bump + // the request-ID that triggers the list's scroll onChange). if viewModel.selectedTrackIndex != nil { - Button("Jump to Now Playing", systemImage: "play.circle.fill") { - // Signal the list to scroll the current track into view (UI-2). The list owns - // the ScrollViewReader proxy; bumping this request-ID triggers its onChange. - jumpToCurrentRequestID += 1 - } - .labelStyle(.iconOnly) - .font(.system(size: 14)) - .foregroundStyle(Color.asAccent) - .help("Jump to now playing") + Button("Jump to Now Playing", systemImage: "play.circle.fill", + action: onJumpToNowPlaying) + .labelStyle(.iconOnly) + .font(.system(size: 14)) + .foregroundStyle(Color.asAccent) + .help("Jump to now playing") } } } @@ -161,7 +265,20 @@ private struct PlaylistControlsView: View { private struct PlaylistItemList: View { @Environment(AudioViewModel.self) var viewModel - @Binding var jumpToCurrentRequestID: Int + /// Read-only scroll request (the OWNER sequences filter-clear before bumping it); + /// observed via `onChange` to scroll the current track into view. + let jumpToCurrentRequestID: Int + /// The REAL queue positions to render (the D7 filter narrows this; actions keep true + /// indices). Unfiltered = all indices. + let visibleIndices: [Int] + /// Reorder (grip drag + drop) is disabled while the filter narrows the list — moving a + /// row relative to HIDDEN neighbours is incoherent; the context-menu moves stay. + let reorderEnabled: Bool + /// Keyboard-command focus for the scroll area. `List` owned key focus for free; a + /// ScrollView/LazyVStack does not, so the ↑/↓/Return/Delete shortcuts are bound to this + /// (`.focused` + default + set-on-tap). OWNED by `PlaylistView` so the filter field's + /// Escape can hand focus back here. + var queueFocused: FocusState.Binding /// Non-nil while the "Info" popover is showing; identifies which row's card is open by its /// stable `QueueItem.id` (dups-safe — keying on the URL popped the card on every duplicate row). @@ -172,11 +289,6 @@ private struct PlaylistItemList: View { /// no drag is in progress. @State private var dropTargetIndex: Int? - /// Keyboard-command focus for the scroll area. `List` owned key focus for free; a - /// ScrollView/LazyVStack does not, so the ↑/↓/Return/Space/Delete shortcuts are bound to this - /// (`.focused` + default + set-on-tap) — the same pattern `FrequencyResponseCanvas` uses. - @FocusState private var queueFocused: Bool - /// Track-number column width sized to the widest index in the list (~8 pt per monospaced /// digit + slack), so a 190-track list reserves room for 3 digits and never wraps "191". private var numberColumnWidth: CGFloat { @@ -184,12 +296,30 @@ private struct PlaylistItemList: View { return CGFloat(digits) * 8 + 6 } + /// ForEach rows keyed by the STABLE `QueueItem.id` — a positional-Int key re-identifies + /// every row on reorder (moves render as content swaps, row-local state resets) — while + /// still carrying the REAL queue index the row's actions need. + private struct VisibleRow: Identifiable { + let index: Int + let item: QueueItem + var id: QueueItem.ID { + item.id + } + } + + private var visibleRows: [VisibleRow] { + visibleIndices.compactMap { index in + guard index < viewModel.queue.count else { return nil } + return VisibleRow(index: index, item: viewModel.queue[index]) + } + } + var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 0) { - ForEach(Array(viewModel.queue.enumerated()), id: \.element.id) { index, item in - queueRow(index: index, item: item) + ForEach(visibleRows) { row in + queueRow(index: row.index, item: row.item) } } } @@ -199,8 +329,8 @@ private struct PlaylistItemList: View { // `List` provided for free (a row tap also sets it); `.focusEffectDisabled` suppresses // the focus ring on the scroll area (the selection tint is the cue). .focusable() - .focused($queueFocused) - .defaultFocus($queueFocused, true) + .focused(queueFocused) + .defaultFocus(queueFocused, true) .focusEffectDisabled() .frame(maxHeight: .infinity) // Dismiss any open Info popover when the queue changes (remove / clear / reorder) @@ -216,6 +346,10 @@ private struct PlaylistItemList: View { // already covers keyboard toggle here (focus-audit nit). .onKeyPress(.delete) { guard let index = viewModel.selectedTrackIndex else { return .ignored } + // Never remove a row the filter is HIDING (break-it MINOR-2): Delete on an + // invisible selection silently removed — and could stop — the playing track + // with no visible target. Visible rows only. + guard visibleIndices.contains(index) else { return .ignored } viewModel.removeTrack(at: index) return .handled } @@ -235,25 +369,34 @@ private struct PlaylistItemList: View { isSelected: viewModel.selectedTrackIndex == index, isNowPlaying: viewModel.isPlaying && viewModel.selectedTrackIndex == index, numberColumnWidth: numberColumnWidth, - dragPayload: QueueDragItem(id: item.id), + // Nil payload while the filter narrows the list = NO grip (the row API's own + // non-reorderable state, built in S10.3): the affordance disappears with the + // capability instead of offering a dead-end drag. The drop guard below stays + // as belt-and-braces. + dragPayload: reorderEnabled ? QueueDragItem(id: item.id) : nil, isDropTarget: dropTargetIndex == index ) - // Identity is the stable `QueueItem.id` (matches the `ForEach` key) so reorders re-render - // the RIGHT rows — a positional `.id(index)` fought the ForEach key and left stale - // now-playing highlights + un-refreshed rows after a move. `scrollTo` uses this id too. + // Identity is the stable `QueueItem.id` (matches the `ForEach` key via `VisibleRow`) + // so reorders re-render the RIGHT rows — a positional key re-identifies every row + // after a move. `scrollTo` targets this id too. .id(item.id) // Reorder: the grip is the `.draggable` source, each row a `.dropDestination` that lands // the dragged item at its position. This is why the queue is a LazyVStack, not a List. .dropDestination(for: QueueDragItem.self) { payloads, _ in dropTargetIndex = nil - guard let fromID = payloads.first?.id else { return false } + guard reorderEnabled, let fromID = payloads.first?.id else { return false } return viewModel.moveByDrop(fromID: fromID, toIndex: index) } isTargeted: { targeted in + // No reorderEnabled guard here (break-it NIT-1): typing a filter mid-drag flips + // reorder OFF, and a guard would swallow the un-target event — latching the + // highlight on a row until the next drag. Tracking the hover is always safe; + // only the DROP is gated (above). dropTargetIndex = targeted ? index : (dropTargetIndex == index ? nil : dropTargetIndex) } .simultaneousGesture( TapGesture().onEnded { - queueFocused = true // a click on a row focuses the queue for the keyboard shortcuts + // A click on a row focuses the queue for the keyboard shortcuts. + queueFocused.wrappedValue = true // Single-click plays the row, so the now-playing card always matches the audio (no // select-without-play state). Re-clicking the playing track is a no-op (no restart). guard !(viewModel.isPlaying && viewModel.selectedTrackIndex == index) else { return } diff --git a/Sources/AdaptiveSound/UI/Settings/PureModeSettingsSection.swift b/Sources/AdaptiveSound/UI/Settings/PureModeSettingsSection.swift index 46994b8..c7fc748 100644 --- a/Sources/AdaptiveSound/UI/Settings/PureModeSettingsSection.swift +++ b/Sources/AdaptiveSound/UI/Settings/PureModeSettingsSection.swift @@ -66,7 +66,7 @@ private struct SignalPathStatusCard: View { if info.fellBackToEnhanced { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(DesignSystem.Color.statusWarning) + .foregroundStyle(DesignSystem.Color.statusWarningText) Text("Pure requested but not available on this device/track — using Enhanced.") .font(.caption) .foregroundStyle(Color.asLabelSecond) diff --git a/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift b/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift index a5c135c..cf7ffcc 100644 --- a/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift +++ b/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift @@ -1,3 +1,4 @@ +import DesignTokenKit import SwiftUI /// The app-owned chrome header (the shell's top band). @@ -35,6 +36,10 @@ struct ChromeBar: View { // left edge lines up with the content below. Height, window background, and the bottom // hairline are owned by AppShell — deliberately not set here. .padding(.horizontal, 16) + // Fixed 60pt band (like the footer): clamp text scale so accessibility sizes don't + // overflow the chrome (the device pill + segmented tabs grow with type). PR 6 — the + // strict-gate clamp guard asserts this stays present. + .dynamicTypeSize(.small ... .xLarge) } } @@ -49,7 +54,7 @@ private struct AppLogoView: View { Image(systemName: "music.note") .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.white) + .foregroundStyle(DesignSystem.Color.onAccent) } .accessibilityHidden(true) } @@ -59,44 +64,79 @@ private struct AppLogoView: View { private struct DevicePillView: View { @Environment(AudioViewModel.self) private var viewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// The device's live output rate (0 when idle → the readout slot stays empty). Enhanced + /// now publishes this too (PR 6), so it's populated on both paths while playing. + private var achievedRate: Double { + viewModel.signalPath.achievedSampleRate + } var body: some View { - Menu { - ForEach(viewModel.availableDevices) { device in - Button(action: { viewModel.selectDevice(device) }, label: { - if device.id == viewModel.selectedDevice?.id { - Label(device.displayName, systemImage: "checkmark") - } else { - Text(device.displayName) - } - }) - } - } label: { - Label( - viewModel.selectedDevice?.name ?? "No Device", - systemImage: viewModel.selectedDevice?.systemIcon ?? "speaker.wave.2" - ) - .font(.callout.weight(.medium)) - .foregroundStyle(Color.asLabel) - .lineLimit(1) - .truncationMode(.tail) - .padding(.horizontal, 10) - .padding(.vertical, 6) - // Fixed width (minWidth == maxWidth), not a range: the pill's width was tracking the - // device NAME, which slid the tab control's left edge on every device change. Fixed → - // tabs' x-origin is invariant (the founder's "fixed top-left"). Long names truncate. - .frame(minWidth: 200, maxWidth: 200, minHeight: 32, alignment: .leading) - .background(Color.asCard) - .clipShape(.rect(cornerRadius: 8, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(Color.asHairline, lineWidth: 0.5) + // The rate readout lives OUTSIDE the Menu's label, beside it in the shared capsule: + // macOS does NOT reliably re-render a Menu's custom label when observed data changes + // (the founder's screenshots showed the rate stuck empty while the hero badge and + // footer — plain views on the same property — updated live; it refreshed only on a + // device switch, which rebuilds the menu). A sibling Text updates like any view. + HStack(spacing: 8) { + Menu { + ForEach(viewModel.availableDevices) { device in + Button(action: { viewModel.selectDevice(device) }, label: { + if device.id == viewModel.selectedDevice?.id { + Label(device.displayName, systemImage: "checkmark") + } else { + Text(device.displayName) + } + }) + } + } label: { + HStack(spacing: 6) { + Image(systemName: viewModel.selectedDevice?.systemIcon ?? "speaker.wave.2") + Text(viewModel.selectedDevice?.name ?? "No Device") + .lineLimit(1) + .truncationMode(.tail) + } + .font(.callout.weight(.medium)) + .foregroundStyle(Color.asLabel) } + .accessibilityLabel("Audio output device") + .accessibilityValue(deviceAccessibilityValue) + .accessibilityHint("Click to choose from available audio output devices") + + Spacer(minLength: 8) + + // D5: the device's live sample rate, digits rolling (`numericText`) when it + // changes. Reserved fixed slot — empty until a rate is known, so the pill's + // fixed width (and the tabs' x-origin) never move. + Text(achievedRate > 0 ? SignalPathInfo.rateString(achievedRate) : "") + .font(DesignSystem.Font.monoSmall) + .foregroundStyle(Color.asLabelSecond) + .monospacedDigit() + .contentTransition(.numericText()) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: achievedRate) + .lineLimit(1) + // The slot fits every rate at default size (SLOT-02); a 9-char hi-res rate + // ("176.4 kHz") at the clamped .xLarge max shrinks to fit rather than + // truncating away the "kHz" unit. + .minimumScaleFactor(0.7) + .frame(width: CGFloat(SlotWidths.chromeSampleRate), alignment: .trailing) + .accessibilityHidden(true) // folded into the Menu's a11y value above } - .fixedSize(horizontal: false, vertical: true) - .accessibilityLabel("Audio output device") - .accessibilityValue(viewModel.selectedDevice?.displayName ?? "No device selected") - .accessibilityHint("Click to choose from available audio output devices") + .padding(.horizontal, 12) + // Fixed width (minWidth == maxWidth), not a range: the pill's width was tracking the + // device NAME, which slid the tab control's left edge on every device change. Fixed → + // tabs' x-origin is invariant (the founder's "fixed top-left"). Long names truncate + // (the text compresses before the spacer's 8pt minimum or the rate slot give way). + .frame(minWidth: 252, maxWidth: 252, minHeight: 32, alignment: .leading) + // The 8a glass "small-control" fill (the .badge role — same white-8% recipe the + // mock's device pill uses), replacing the old flat card + hand-drawn hairline. + .glassPanel(.badge, in: Capsule()) + } + + private var deviceAccessibilityValue: String { + let name = viewModel.selectedDevice?.displayName ?? "No device selected" + guard achievedRate > 0 else { return name } + return "\(name), \(SignalPathInfo.rateString(achievedRate).replacing(" kHz", with: " kilohertz"))" } } diff --git a/Sources/AdaptiveSound/UI/Shell/ErrorBanner.swift b/Sources/AdaptiveSound/UI/Shell/ErrorBanner.swift index 90e3ef4..39b7d01 100644 --- a/Sources/AdaptiveSound/UI/Shell/ErrorBanner.swift +++ b/Sources/AdaptiveSound/UI/Shell/ErrorBanner.swift @@ -56,7 +56,7 @@ struct ErrorBanner: View { private func card(message: String) -> some View { HStack(alignment: .firstTextBaseline, spacing: DesignSystem.Spacing.small) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(DesignSystem.Color.statusWarning) + .foregroundStyle(DesignSystem.Color.statusWarningText) .accessibilityHidden(true) // the message Text carries the meaning Text(message) @@ -82,7 +82,10 @@ struct ErrorBanner: View { } .padding(.horizontal, DesignSystem.Spacing.medium) .padding(.vertical, DesignSystem.Spacing.small) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: DesignSystem.Radius.container)) + // Regime A/`.overlay` (S10.7 §3.1): a transient banner floating OVER variable tab + // content — the one Material-sanctioned role; substrate/fallbacks owned by the token + // layer, shape + hairline stay site-owned. + .glassPanel(.overlay(.ultraThin), in: RoundedRectangle(cornerRadius: DesignSystem.Radius.container)) .overlay( RoundedRectangle(cornerRadius: DesignSystem.Radius.container) .stroke(DesignSystem.Color.hairline, lineWidth: DesignSystem.ShellMetrics.hairline) diff --git a/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift b/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift index 334cab8..0368db9 100644 --- a/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift +++ b/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift @@ -263,20 +263,14 @@ private struct FooterScrubber: View { GeometryReader { geo in let trackWidth = geo.size.width ZStack(alignment: .leading) { - Capsule() - .fill(DesignSystem.Color.card) - .frame(height: DesignSystem.Footer.scrubberTrackHeight) - .frame(maxHeight: .infinity, alignment: .center) - - Capsule() - .fill(fillColor) - .frame( - width: max(trackWidth * CGFloat(fraction), 0), - height: DesignSystem.Footer.scrubberTrackHeight - ) - .frame(maxHeight: .infinity, alignment: .center) - // Ease the play→pause fill shift (accent ↔ accent·0.5); position width is - // NOT animated (it tracks playback). Reduce-Motion gated. + // The shared 8a carved groove (PR 6 — same surface as the inspector sliders). + // Fill follows playback; the dark-only teal glow shows only while actually + // playing (paused = dim teal, interrupted = grey — no mismatched glow). + CarvedGroove(fillFraction: fraction, + fillColor: fillColor, + glow: viewModel.isPlaying && !isInterrupted) + // Ease the play→pause fill-color shift (accent ↔ accent·0.5); the width + // tracks playback and is not animated. Reduce-Motion gated. .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: viewModel.isPlaying) // Thumb reveals on hover/drag only (cleaner than an always-on thumb). @@ -296,11 +290,10 @@ private struct FooterScrubber: View { } } + /// The hover/drag thumb: the shared carved knob (its bottom-shade cue replaces the old + /// hand-painted drop shadow — the PR-1 shadow literal is retired by adoption, not tokenised). private var thumbView: some View { - Circle() - .fill(DesignSystem.Color.label) - .frame(width: DesignSystem.Footer.thumbSize, height: DesignSystem.Footer.thumbSize) - .shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 1) + CarvedKnob(size: DesignSystem.Footer.thumbSize) .scaleEffect(isHovered || isDragging ? 1.15 : 1.0) .animation(reduceMotion ? nil : .easeOut(duration: 0.12), value: isHovered || isDragging) } @@ -382,7 +375,7 @@ private struct FooterSignalSlot: View { if info.interrupted { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 10)) - .foregroundStyle(DesignSystem.Color.statusWarning) + .foregroundStyle(DesignSystem.Color.statusWarningText) Text("Disconnected") .font(DesignSystem.Font.monoSmall) .foregroundStyle(DesignSystem.Color.labelSecondary) diff --git a/Sources/AdaptiveSound/UI/Shell/QueueToast.swift b/Sources/AdaptiveSound/UI/Shell/QueueToast.swift index d564334..bf9c32b 100644 --- a/Sources/AdaptiveSound/UI/Shell/QueueToast.swift +++ b/Sources/AdaptiveSound/UI/Shell/QueueToast.swift @@ -68,7 +68,8 @@ struct QueueToast: View { } .padding(.horizontal, DesignSystem.Spacing.medium) .padding(.vertical, DesignSystem.Spacing.small) - .background(.ultraThinMaterial, in: .capsule) + // Regime A/`.overlay` (S10.7 §3.1): transient toast floating over variable content. + .glassPanel(.overlay(.ultraThin), in: Capsule()) .overlay(Capsule().stroke(DesignSystem.Color.hairline, lineWidth: DesignSystem.ShellMetrics.hairline)) .contentShape(Capsule()) } diff --git a/Sources/AdaptiveSound/UI/Spectrum/SpectrumAnalyzerView.swift b/Sources/AdaptiveSound/UI/Spectrum/SpectrumAnalyzerView.swift index b09f7e5..3374851 100644 --- a/Sources/AdaptiveSound/UI/Spectrum/SpectrumAnalyzerView.swift +++ b/Sources/AdaptiveSound/UI/Spectrum/SpectrumAnalyzerView.swift @@ -1,42 +1,114 @@ +import DesignTokenKit import SwiftUI // MARK: - Spectrum Analyzer View -/// Displays real-time FFT magnitude bars sourced from `AudioViewModel.spectrumBars`. +/// Real-time FFT bars + peak-hold caps inside the Regime-B LENS, with the 8a instrument +/// dressing (PR 5, now that the D6 frame exists): a "0 dB" reference label ABOVE the bar +/// field, four horizontal gridline hairlines behind the bars, and the 20 Hz–20 kHz axis strip +/// BELOW it. Labels never overlay the bars (§7 R4 pair 9: the palette's near-white lime would +/// sink small text — the strips resolve it by placement, not a scrim). /// -/// The ViewModel updates `spectrumBars` on the main thread at ~20 Hz via a Timer. -/// SwiftUI's `@Observable` machinery propagates changes to this view automatically; -/// no `TimelineView` or fake random data is needed. -/// -/// Accessibility: the view is hidden from the accessibility tree (it is a purely -/// decorative animation). Screen readers will still see the playback controls. +/// Bars + caps update from the same 20 Hz main-thread tick (one `@Observable` invalidation); +/// caps freeze on pause structurally (the tracker is time-fed). The bar FIELD stays hidden +/// from accessibility (decorative animation); the LENS ELEMENT itself is exposed by HeroRow. struct SpectrumAnalyzerView: View { @Environment(AudioViewModel.self) var viewModel @Environment(\.accessibilityReduceMotion) var reduceMotion + private enum CapMetrics { + static let height: CGFloat = 2 + static let gapAboveBar: CGFloat = 4 + static let opacity: Double = 0.5 + } + + /// Decorative axis text (a11y-hidden; sub-10pt is allowed for decorative-only, §3.2). + private enum AxisMetrics { + static let font = SwiftUI.Font.system(size: 9, design: .monospaced) + static let gridlineCount = 4 + } + var body: some View { + VStack(spacing: 3) { + HStack { + Spacer() + Text("0 dB") + .font(AxisMetrics.font) + .foregroundStyle(DesignSystem.Color.labelSecondary) + } + barField + axisStrip + } + .padding(DesignSystem.Spacing.small) + .glassPanel(.lens, in: RoundedRectangle(cornerRadius: CGFloat(GlassDecor.lensRadius), + style: .continuous)) + .accessibilityHidden(true) // HeroRow exposes the lens element + its action + } + + // MARK: Bar field (bars + caps over the gridlines) + + private var barField: some View { let bars = viewModel.spectrumBars - HStack(alignment: .bottom, spacing: 2) { - ForEach(0 ..< bars.count, id: \.self) { index in - // Compute normalized horizontal position: 0 (left/low-freq) to 1 (right/high-freq) - let t = bars.count > 1 ? Float(index) / Float(bars.count - 1) : 0 - - // Get the frequency-based gradient for this bar - let barGradient = SpectrumColorPalette.gradientAt(t) - - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(barGradient) - // Height is in [0, 1]; clamp defensively before scaling to 50pt. - .frame(height: CGFloat(min(max(bars[index], 0), 1)) * 50) - // Animate height changes with a short ease-out. - // When reduceMotion is on, skip the animation entirely. - .animation( - reduceMotion ? nil : .easeOut(duration: 0.08), - value: bars[index] - ) + let caps = viewModel.peakCaps + return GeometryReader { geo in + let maxBarHeight = geo.size.height + HStack(alignment: .bottom, spacing: 2) { + ForEach(0 ..< bars.count, id: \.self) { index in + let t = bars.count > 1 ? Float(index) / Float(bars.count - 1) : 0 + let barGradient = SpectrumColorPalette.gradientAt(t) + let barHeight = CGFloat(min(max(bars[index], 0), 1)) * maxBarHeight + let capValue = index < caps.count ? CGFloat(min(max(caps[index], 0), 1)) : 0 + + ZStack(alignment: .bottom) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(barGradient) + .frame(height: barHeight) + .animation(reduceMotion ? nil : .easeOut(duration: 0.08), + value: bars[index]) + + if capValue * maxBarHeight > barHeight + 1 { + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(barGradient) + .opacity(CapMetrics.opacity) + .frame(height: CapMetrics.height) + .offset(y: -(capValue * maxBarHeight + CapMetrics.gapAboveBar)) + .animation(reduceMotion ? nil : .easeOut(duration: 0.08), + value: caps[index]) + } + } + .frame(maxHeight: .infinity, alignment: .bottom) + } } + .background { gridlines(in: geo.size) } } .opacity(viewModel.isPlaying ? 1.0 : 0.4) - .accessibilityHidden(true) + } + + /// Four horizontal hairlines (8a: white 4–6% — the glass hairline token) behind the bars. + private func gridlines(in size: CGSize) -> some View { + VStack(spacing: 0) { + ForEach(0 ..< AxisMetrics.gridlineCount, id: \.self) { _ in + Rectangle() + .fill(Color.asHairline) + .frame(height: 0.5) + Spacer(minLength: 0) + } + } + .frame(height: size.height) + } + + // MARK: Axis strip (20 Hz – 20 kHz, decorative) + + private var axisStrip: some View { + HStack { + Text("20 Hz").font(AxisMetrics.font) + Spacer() + Text("200 Hz").font(AxisMetrics.font) + Spacer() + Text("2 kHz").font(AxisMetrics.font) + Spacer() + Text("20 kHz").font(AxisMetrics.font) + } + .foregroundStyle(DesignSystem.Color.labelTertiary) } } diff --git a/Sources/AdaptiveSound/UI/Tabs/EQTabView.swift b/Sources/AdaptiveSound/UI/Tabs/EQTabView.swift index 1c8cae6..561a563 100644 --- a/Sources/AdaptiveSound/UI/Tabs/EQTabView.swift +++ b/Sources/AdaptiveSound/UI/Tabs/EQTabView.swift @@ -90,7 +90,8 @@ private struct EQRecallBanner: View { } .padding(.horizontal, 16) .padding(.vertical, 8) - .background(.ultraThinMaterial, in: .capsule) + // Regime A/`.overlay` (S10.7 §3.1): transient recall banner floating over the EQ canvas. + .glassPanel(.overlay(.ultraThin), in: Capsule()) .overlay(Capsule().stroke(Color.asHairline, lineWidth: 0.5)) } } diff --git a/Sources/DesignTokenKit/GlassDecor.swift b/Sources/DesignTokenKit/GlassDecor.swift new file mode 100644 index 0000000..73e201b --- /dev/null +++ b/Sources/DesignTokenKit/GlassDecor.swift @@ -0,0 +1,99 @@ +// GlassDecor — the Regime-B edge-decoration data (S10.7 PR 3+, design §3.2): the strata a +// `.glassPanel` fill role composes (specular top rim, glass hairline, bottom light bleed, +// drop shadow) and the concentric radii. DATA ONLY — the app-side modifier composes it. +// Values are the 8a recipe's dark side; light values follow the §3.2 translation grammar +// (rim STAYS white but brighter; hairline FLIPS dark; bleed is DROPPED; shadows lighter and +// tighter — never an inversion). + +import Foundation + +public enum GlassDecor { + // MARK: Radii (staged per consumer — TOK-01 asserts the concentric chain stays monotone) + + /// Inspector panel (8a: radius 22 — the outermost app panel). + public static let panelRadius: Double = 22 + /// Analyzer lens (8a: radius 20; PR 3's first consumer). + public static let lensRadius: Double = 20 + + // MARK: Specular top rim (grammar rule 1: white on BOTH sides, brighter in light) + + public static let rim = AppearancePair( + light: .gray(1.0, alpha: 0.55), + dark: .gray(1.0, alpha: 0.17) + ) + + // MARK: Glass hairline (grammar rule 2: flips dark in light; FIRST token with real + + // Increase-Contrast variants — the §3.2 "stronger hairlines under IC" promise) + + public static let glassHairline = AppearancePair( + light: .gray(0.0, alpha: 0.10), + dark: .gray(1.0, alpha: 0.05), + lightHighContrast: .gray(0.0, alpha: 0.30), + darkHighContrast: .gray(1.0, alpha: 0.25) + ) + + // MARK: Bottom light bleed (grammar rule 3: DARK-ONLY — depth in light comes from shadow) + + public static let bleedDark: RGBAColor = .gray(1.0, alpha: 0.12) + public static let bleedHeight: Double = 24 + + // MARK: Carved sliders (PR 5 — 8a: 5pt inset tracks, 14pt knobs, dark-only teal glow) + + /// Knob diameter — in the Kit because the interaction math (pointer→fraction mapping + /// over the knob's inset travel) and the visuals must share ONE value; a drift between + /// them re-creates the mouse-down value-jump at the track extremes. + public static let sliderKnobSize: Double = 14 + /// Carved groove (track) height — shared by the inspector sliders AND the footer scrubber + /// (PR 6) so the two carved surfaces are visually identical. + public static let carvedTrackHeight: Double = 5 + /// Carved track base fill (the groove). + public static let carvedTrack = AppearancePair( + light: .gray(0.0, alpha: 0.10), + dark: .gray(1.0, alpha: 0.08) + ) + /// The knob fill. White on BOTH sides for now — a pair (not a constant) because the + /// PR-6 non-text-contrast pass owns the light-side value (white knob on the white-based + /// light panel is a known open item). + public static let knobFill = AppearancePair(both: .gray(1.0)) + /// The inset top shade inside the groove (8a `inset 0 1px 2px rgba(0,0,0,.4)`; light + /// per grammar: much fainter). + public static let carvedShadeDark: RGBAColor = .gray(0.0, alpha: 0.40) + public static let carvedShadeLight: RGBAColor = .gray(0.0, alpha: 0.15) + /// The teal fill's glow — DARK-ONLY (grammar rule 6), 8a `0 0 8-10px rgba(63,208,186,.45)`. + public static let sliderGlowDark = RGBAColor(red: 63.0 / 255.0, green: 208.0 / 255.0, + blue: 186.0 / 255.0, alpha: 0.45) + /// The knob's bottom inner shade (both appearances — it's a physical cue, not emission). + public static let knobShade: RGBAColor = .gray(0.0, alpha: 0.25) + + // MARK: Hero (PR 4 — 8a: teal title halo, dark-only per grammar rule 6; pulsing dot) + + /// The hero title's teal text-halo — DARK-ONLY (light drops emissive cues, grammar + /// rule 6). A single constant, not a pair: the `.heroTitle()` modifier (the sanctioned + /// appearance reader) applies it only in dark. + public static let heroHaloDark = RGBAColor(red: 41.0 / 255.0, green: 182.0 / 255.0, + blue: 164.0 / 255.0, alpha: 0.25) + public static let heroHaloRadius: Double = 16 + public static let heroHaloOffsetY: Double = 2 + + /// The ENHANCED badge's pulsing dot (8a: 1.6 s cycle, opacity 1 → 0.4) — the phase + /// animator runs each half-cycle. + public static let pulseHalfCycleSeconds: Double = 0.8 + public static let pulseDimOpacity: Double = 0.4 + /// Base badge capsule height (@ScaledMetric-scaled at the call site, 8a: 22pt). + public static let badgeBaseHeight: Double = 22 + + // MARK: Drop shadow (grammar rule 4: light = lighter AND tighter — tuned from the PR-3 + + // founder screenshots: the first pass at literal half-of-dark (0.30 @ 18) read as a + // gray smudge on the light window; native macOS light panels sit nearer 0.15) + + public static let shadowColor = AppearancePair( + light: .gray(0.0, alpha: 0.15), + dark: .gray(0.0, alpha: 0.60) + ) + public static let shadowRadiusDark: Double = 36 + public static let shadowRadiusLight: Double = 12 + public static let shadowOffsetYDark: Double = 14 + public static let shadowOffsetYLight: Double = 5 +} diff --git a/Sources/DesignTokenKit/GlowFieldSpec.swift b/Sources/DesignTokenKit/GlowFieldSpec.swift new file mode 100644 index 0000000..26a6bed --- /dev/null +++ b/Sources/DesignTokenKit/GlowFieldSpec.swift @@ -0,0 +1,130 @@ +// GlowFieldSpec — geometry + falloff data for the ambient content glows, and the pure +// visibility resolver (S10.7 PR 2, design §3.3). Pure data/functions per the Kit charter; +// the app-side `GlowField` view (Regime C decoration) renders from this and nothing else, +// and the R4 geometric audit samples the SAME data — tuning a center re-verifies the audit +// automatically. + +import Foundation + +// MARK: - Spec + +public enum GlowFieldSpec { + /// One ambient glow: its color pair and its geometry ENTIRELY in UNIT space — center + /// anchor AND ellipse size as fractions of the container (founder round-1 decision, + /// 2026-07-17: proportional sizing reproduces the mock's coverage at any window size; + /// fixed point-sizes read as three subtle pools on a large window). + public struct Glow: Sendable, Equatable { + public let color: AppearancePair + public let unitWidth: Double + public let unitHeight: Double + public let unitCenterX: Double + public let unitCenterY: Double + + public init(color: AppearancePair, unitWidth: Double, unitHeight: Double, + unitCenterX: Double, unitCenterY: Double) { + self.color = color + self.unitWidth = unitWidth + self.unitHeight = unitHeight + self.unitCenterX = unitCenterX + self.unitCenterY = unitCenterY + } + } + + /// The three 8a glows, centers AND sizes DERIVED FROM THE MOCK'S CSS (its 1120×720 card + /// → our 1120×596 tab space; blue is an INTERIOR midfield glow, not an edge bleed). + /// Order is render order (teal under lime under blue). Founder-tunable in the by-eye + /// rounds; the R4 geometric audit re-runs against whatever lands here. + /// PR-6 forward note: when the shell bands go glass, the field migrates to a + /// shell-level mount; the equivalent WINDOW-space centers are teal (0.214, 0.139), + /// lime (0.786, 0.889), blue (0.634, 0.597). + public static let glows: [Glow] = [ + Glow(color: Palette.glowTeal, unitWidth: 0.643, unitHeight: 0.940, + unitCenterX: 0.214, unitCenterY: 0.067), + Glow(color: Palette.glowLime, unitWidth: 0.679, unitHeight: 1.007, + unitCenterX: 0.786, unitCenterY: 0.973), + Glow(color: Palette.glowBlue, unitWidth: 0.375, unitHeight: 0.638, + unitCenterX: 0.634, unitCenterY: 0.621), + ] + + /// The falloff profile — EXACT-LINEAR to match the mock's CSS `radial-gradient( + /// closest-side, peak → 0)` (PR-2 design review: the blur pass only rounds the apex; + /// the mid-field is a linear ramp, NOT a plateau): peak at center, `midAlphaFactor` + /// of peak at `midStop`, clear at the edge — with 0.45 @ 0.55 both segments slope −1. + public static let falloffMidStop: Double = 0.55 + public static let falloffMidAlphaFactor: Double = 0.45 + + /// Fraction of peak alpha at normalized elliptical distance `t` ∈ [0, ∞) from a glow's + /// center (t = 1 is the ellipse edge). The single profile source: the render gradient's + /// stops AND the R4 geometric audit both read this. + public static func falloffFraction(at t: Double) -> Double { + if t <= 0 { return 1 } + if t >= 1 { return 0 } + if t <= falloffMidStop { + return 1 - (1 - falloffMidAlphaFactor) * (t / falloffMidStop) + } + return falloffMidAlphaFactor * (1 - (t - falloffMidStop) / (1 - falloffMidStop)) + } + + /// Seam feather (points): the glow fades to nothing over this run at the top/bottom + /// edges so the flat chrome/footer bands don't meet a lit field across a 0.5pt hairline + /// (PR-2 review MAJOR 6: up to ΔRGB +39 otherwise). PERMANENT for this sprint: PR 6 + /// deliberately kept the band SURFACES quiet (D4), so the "goes away when the bands go + /// glass" plan did not happen — revisit only with a shell-glass wave (post-R1). + public static let seamFeather: Double = 24 + + /// The composite glow color at a unit point in a container of the given size: + /// every glow's falloff-attenuated color folded over the window base, in render order. + /// This is the function the R4 geometric audit samples. `overrideColors` (D8, PR 7) is + /// the per-slot sampled-palette override — a `nil` slot keeps the brand token; entries + /// carry their OWN alpha (the clamp forces the slot's token alpha). The render side and + /// the audit fold pass the same overrides, so tuning either re-verifies the other. + public static func compositeBackdrop(unitX: Double, unitY: Double, + containerWidth: Double, containerHeight: Double, + appearance: TokenAppearance, + overrideColors: [RGBAColor?]? = nil) -> RGBAColor { + var backdrop = Palette.window.value(for: appearance) + let pointX = unitX * containerWidth + let pointY = unitY * containerHeight + for (slot, glow) in glows.enumerated() { + let halfWidth = glow.unitWidth * containerWidth / 2 + let halfHeight = glow.unitHeight * containerHeight / 2 + let deltaX = (pointX - glow.unitCenterX * containerWidth) / halfWidth + let deltaY = (pointY - glow.unitCenterY * containerHeight) / halfHeight + let t = (deltaX * deltaX + deltaY * deltaY).squareRoot() + let fraction = falloffFraction(at: t) + guard fraction > 0 else { continue } + // Defensive length check: a short override array means brand for the tail slots + // (public Kit API — never trap on a caller's array shape). + let override = overrideColors.flatMap { slot < $0.count ? $0[slot] : nil } + let color = override ?? glow.color.value(for: appearance) + backdrop = color.opacity(color.alpha * fraction).over(backdrop) + } + return backdrop + } + + /// Normalized elliptical distance from the TEAL glow's center at a unit point — the + /// tertiary placement rule's geometry (§3.3: labelTertiary never inside the teal CORE, + /// core = t ≤ falloffMidStop). With fully-proportional geometry this is container-size- + /// independent, but the size parameters stay so the audit reads one calling convention. + public static func tealDistance(unitX: Double, unitY: Double, + containerWidth: Double, containerHeight: Double) -> Double { + let teal = glows[0] + let deltaX = (unitX - teal.unitCenterX) * containerWidth / (teal.unitWidth * containerWidth / 2) + let deltaY = (unitY - teal.unitCenterY) * containerHeight / (teal.unitHeight * containerHeight / 2) + return (deltaX * deltaX + deltaY * deltaY).squareRoot() + } +} + +// MARK: - Visibility resolver (RES-04) + +/// The glow field renders ONLY in dark appearance (PR 2): the 8a mock is dark-only, and the +/// review math shows any mid-luminance hue alpha-composited over the near-white light window +/// DARKENS it — a stain, unfixable by alpha choice. Light-mode ambience needs re-derived +/// luminance-positive pastels (S10.8 / D8 material). And it is TRANSLUCENCY DECORATION: +/// suppressed whenever the user asks for reduced transparency — including under Increase +/// Contrast alone (RES-02 doctrine: never depend on the OS coupling IC→RT). +public func glowFieldIsVisible(appearance: TokenAppearance, + reduceTransparency: Bool, + increasedContrast: Bool) -> Bool { + appearance == .dark && !reduceTransparency && !increasedContrast +} diff --git a/Sources/DesignTokenKit/NowPlayingLayout.swift b/Sources/DesignTokenKit/NowPlayingLayout.swift new file mode 100644 index 0000000..da70977 --- /dev/null +++ b/Sources/DesignTokenKit/NowPlayingLayout.swift @@ -0,0 +1,32 @@ +// NowPlayingLayout — the PR-5 restructure's layout data (design §5), in the Kit so the +// §7.1 layout-arithmetic test can ASSERT the width/height budget headlessly instead of +// trusting prose. The app-side views consume these; the test derives from them. + +import Foundation + +public enum NowPlayingLayout { + /// The fixed trailing inspector column (founder decision D2 — 8a). + public static let inspectorWidth: Double = 260 + /// Content inset from the window edges (8a alignment grid). + public static let contentInset: Double = 16 + /// Gap between the hero text block and the lens, and between the queue and inspector. + public static let regionGap: Double = 20 + /// The analyzer lens frame (founder decision D6 — 8a hero-right, flexing). + public static let lensMinWidth: Double = 400 + public static let lensMaxWidth: Double = 560 + public static let lensHeight: Double = 122 + /// Minimum usable widths the arithmetic test asserts at the 880pt window minimum. + public static let queueMinWidth: Double = 320 + public static let heroTextMinWidth: Double = 300 + /// The shell's fixed bands. `DesignSystem.ShellMetrics` FORWARDS these (single-source + /// invariant, PR 1a pattern): the shell lays out from the same values the §5 arithmetic + /// test derives from, so neither can drift behind the other. + public static let windowMinWidth: Double = 880 + public static let windowMinHeight: Double = 640 + public static let chromeHeight: Double = 60 + public static let footerHeight: Double = 64 + /// The §7.1 headroom factor absorbing Dynamic-Type growth of the hero block in the + /// vertical budget (macOS max text size ≈ 1.4× default — documented approximation; the + /// hero deliberately scales, the assertion is that scaling never starves the queue). + public static let maxTypeHeadroom: Double = 1.4 +} diff --git a/Sources/DesignTokenKit/Palette.swift b/Sources/DesignTokenKit/Palette.swift new file mode 100644 index 0000000..cc605a4 --- /dev/null +++ b/Sources/DesignTokenKit/Palette.swift @@ -0,0 +1,187 @@ +// Palette — the single source for every color the app paints (S10.7 single-source +// invariant, design §3.2): `DesignSystem.Color.*` re-exports THESE values; the R4 contrast +// audit composites THESE values. A color that exists here and (differently) in the app is +// the "hand-mirror drift" failure the invariant exists to prevent. +// +// Values are byte-identical to the pre-S10.7 `DesignSystem.swift` literals (PR 1a is a +// zero-visual-change refactor); the D10 deep-base re-tune arrives in PR 2 by EDITING these. + +import Foundation + +// MARK: - Palette + +public enum Palette { + // MARK: Surfaces (elevation stack) + + /// Window base. Dark = the 8a DEEP base #0e1013 (D10, PR 2 — the release look: every 8a + /// glow/fill/rim value is tuned against this; the pre-D10 #1E1E1E washed them out). + /// Light untouched (the light grammar re-derives, never inverts — design §3.2). + public static let window = AppearancePair( + light: .gray(0.93), + dark: RGBAColor(red: 14.0 / 255.0, green: 16.0 / 255.0, blue: 19.0 / 255.0) + ) + public static let card = AppearancePair(light: .gray(1.0), dark: .gray(1.0, alpha: 0.045)) + public static let panel = AppearancePair(light: .gray(1.0), dark: .gray(1.0, alpha: 0.06)) + public static let hairline = AppearancePair( + light: .gray(0.0, alpha: 0.12), + dark: .gray(1.0, alpha: 0.08) + ) + + // MARK: Labels (WCAG-audited hierarchy — see ContrastAuditTests) + + public static let label = AppearancePair(light: .gray(0.0, alpha: 0.90), dark: .gray(1.0, alpha: 0.92)) + public static let labelSecondary = AppearancePair( + light: .gray(0.0, alpha: 0.62), + dark: .gray(1.0, alpha: 0.55) + ) + public static let labelTertiary = AppearancePair( + light: .gray(0.0, alpha: 0.55), + dark: .gray(1.0, alpha: 0.48) + ) + /// WCAG-exempt (disabled text). + public static let labelDisabled = AppearancePair( + light: .gray(0.0, alpha: 0.28), + dark: .gray(1.0, alpha: 0.25) + ) + + // MARK: Accent family (appearance-independent — the teal reads on both) + + public static let accent = AppearancePair(both: RGBAColor(red: 0.161, green: 0.714, blue: 0.643)) // #29B6A4 + public static let accentDeep = AppearancePair(both: RGBAColor(red: 0.078, green: 0.537, blue: 0.478)) // #148979 + /// Foreground ON the accent (play glyph over teal). Known ≈2.5:1 — pre-existing, + /// flagged to the founder (design §7 R4 scope guard), not gated here. + public static let onAccent = AppearancePair(both: .gray(1.0)) + /// Alternate accent (swap-in blue). + public static let blue = AppearancePair(both: RGBAColor(red: 0.039, green: 0.518, blue: 1.0)) // #0A84FF + + // MARK: Status + + // + // S10.7 PR 6 (founder decision — "split text vs fill"): the vivid `status*` tokens are the + // FILL/indicator colors (meter hot-bar, status dots — non-text, WCAG 3:1); a `status*Text` + // variant carries the darker, AA-legible shade for TEXT/glyph sites (4.5:1). On DARK the + // vivid value already clears text AA (R4-LEG-03 dark / R4-BADGE-01 dark), so the text + // variant differs only in LIGHT — where vivid orange/red on near-white is illegible. + + public static let statusWarning = AppearancePair(both: RGBAColor(red: 1.0, green: 0.623, blue: 0.039)) // #FF9F0A + /// NEW in S10.7 (PR 1a): the clipping/over-level red the loudness meters previously + /// hand-painted as `Color.red` (design §3.1 disposition). Values are SwiftUI's palette + /// red AS RESOLVED ON macOS 26 — #FF383C light / #FF4245 dark, exact fractions + /// (empirically probed: `Color.red.resolve(in:)` ≡ `NSColor.systemRed`; the classic + /// pre-26 #FF3B30/#FF453A is a visible hue shift — review BLOCKER-1). This is the FILL + /// value (meter hot-bar, non-text 3:1); text sites use `statusErrorText`. + public static let statusError = AppearancePair( + light: RGBAColor(red: 1.0, green: 56.0 / 255.0, blue: 60.0 / 255.0), + dark: RGBAColor(red: 1.0, green: 66.0 / 255.0, blue: 69.0 / 255.0) + ) + /// The TEXT/glyph variant of the error red (PR 6, D-split). Light = a dark red that clears + /// AA text on the darkest audited light surface (window ≈ 0.848 L); dark = the vivid value + /// (already AA on the deep base). #A3000F light. + public static let statusErrorText = AppearancePair( + light: RGBAColor(red: 163.0 / 255.0, green: 0.0, blue: 15.0 / 255.0), + dark: RGBAColor(red: 1.0, green: 66.0 / 255.0, blue: 69.0 / 255.0) + ) + /// The TEXT/glyph variant of the warning orange (PR 6, D-split). Light = a dark amber that + /// clears AA text on the light badge fill (≈ 0.737 L, the worst warning-text backdrop); + /// dark = the vivid orange (already AA there). #6E4400 light. + public static let statusWarningText = AppearancePair( + light: RGBAColor(red: 110.0 / 255.0, green: 68.0 / 255.0, blue: 0.0), + dark: RGBAColor(red: 1.0, green: 0.623, blue: 0.039) + ) + + // MARK: Row tints (derived from accent — appearance-independent) + + public static let rowNowPlaying = AppearancePair(both: accent.light.opacity(0.25)) + public static let rowSelected = AppearancePair(both: accent.light.opacity(0.12)) + + // MARK: Icon-fill gradient stops (app-mark squircle / play button — appearance-independent) + + /// The two upper stops of `DesignSystem.Gradient.iconFill` (#3FD0BA, #1FA893 as the + /// shipped rounded doubles — byte-identity to the pre-Kit literals beats hex purity); + /// the third stop is `accentDeep`. + public static let iconFillTop = AppearancePair(both: RGBAColor(red: 0.247, green: 0.816, blue: 0.729)) + public static let iconFillMid = AppearancePair(both: RGBAColor(red: 0.122, green: 0.659, blue: 0.576)) + + // MARK: Glass-look fills (Regime B — design §3.1; staged per consumer) + + /// The analyzer lens fill (8a: `rgba(16,18,21,.42)` — a darker inset against the glowed + /// field). Light per the §3.2 grammar: white-based glass. Under Reduce Transparency / + /// Increase Contrast the resolver serves the OPAQUE composite (fill over window), + /// derived — never a third hand-kept value. + public static let lensFill = AppearancePair( + light: .gray(1.0, alpha: 0.55), + dark: RGBAColor(red: 16.0 / 255.0, green: 18.0 / 255.0, blue: 21.0 / 255.0, alpha: 0.42) + ) + + /// Hero badge capsules (8a "small controls": white 7–9% fills). Light per the grammar: + /// a faint dark wash + the glass hairline carries the edge. RT/IC → opaque composite, + /// derived by the resolver like every fill role. + public static let badgeFill = AppearancePair( + light: .gray(0.0, alpha: 0.06), + dark: .gray(1.0, alpha: 0.08) + ) + + /// The inspector panel fill (8a: `rgba(30,33,38,.5)`). Light per the grammar: white-based + /// glass, one notch stronger than the lens so the column reads as the room's wall, not a + /// second lens. Same derived RT/IC-opaque contract. + public static let panelFill = AppearancePair( + light: .gray(1.0, alpha: 0.60), + dark: RGBAColor(red: 30.0 / 255.0, green: 33.0 / 255.0, blue: 38.0 / 255.0, alpha: 0.5) + ) + + // MARK: Ambient glow field (S10.7 PR 2 — design §3.3) + + /// The three 8a content glows. Dark alphas are the 8a spec (.28/.12/.10 over the deep + /// base); light alphas follow the §3.2 grammar rule 5 (~1/3 — ambience, not smears). + /// D8 pre-binding: when art-sampling lands (PR 7), sampled colors CLAMP into ranges + /// derived from these tokens, so the R4 audit keeps enumerating bounded worst cases. + public static let glowTeal = AppearancePair( + light: RGBAColor(red: 41.0 / 255.0, green: 182.0 / 255.0, blue: 164.0 / 255.0, alpha: 0.09), + dark: RGBAColor(red: 41.0 / 255.0, green: 182.0 / 255.0, blue: 164.0 / 255.0, alpha: 0.28) + ) + public static let glowLime = AppearancePair( + light: RGBAColor(red: 200.0 / 255.0, green: 240.0 / 255.0, blue: 106.0 / 255.0, alpha: 0.04), + dark: RGBAColor(red: 200.0 / 255.0, green: 240.0 / 255.0, blue: 106.0 / 255.0, alpha: 0.12) + ) + public static let glowBlue = AppearancePair( + light: RGBAColor(red: 79.0 / 255.0, green: 178.0 / 255.0, blue: 214.0 / 255.0, alpha: 0.033), + dark: RGBAColor(red: 79.0 / 255.0, green: 178.0 / 255.0, blue: 214.0 / 255.0, alpha: 0.10) + ) + + // MARK: Registry (drives the invariant + audit tests — a token missing here is untested) + + /// Every pair above, by name. TOK tests iterate this; keep it in declaration order. + public static let all: [(name: String, pair: AppearancePair)] = [ + ("window", window), ("card", card), ("panel", panel), ("hairline", hairline), + ("label", label), ("labelSecondary", labelSecondary), ("labelTertiary", labelTertiary), + ("labelDisabled", labelDisabled), + ("accent", accent), ("accentDeep", accentDeep), ("onAccent", onAccent), ("blue", blue), + ("statusWarning", statusWarning), ("statusError", statusError), + ("statusWarningText", statusWarningText), ("statusErrorText", statusErrorText), + ("rowNowPlaying", rowNowPlaying), ("rowSelected", rowSelected), + ("iconFillTop", iconFillTop), ("iconFillMid", iconFillMid), + ("glowTeal", glowTeal), ("glowLime", glowLime), ("glowBlue", glowBlue), + ("lensFill", lensFill), ("badgeFill", badgeFill), ("panelFill", panelFill), + ("glassRim", GlassDecor.rim), ("glassHairline", GlassDecor.glassHairline), + ("glassShadow", GlassDecor.shadowColor), + ("carvedTrack", GlassDecor.carvedTrack), ("knobFill", GlassDecor.knobFill), + ] +} + +// MARK: - Slot widths (fixed-slot fit data — §7.1 SlotFitTests) + +/// Fixed-width text slots whose widest legitimate string must fit (the S9 LUFS-truncation +/// class, asserted headlessly). Only slots under test live here; each new readout brings its +/// slot in the PR that adds it. The app-side `DesignSystem.Footer` re-exports these. +public enum SlotWidths { + /// Footer scrubber time label ("88:88" is the widest mm:ss). + public static let footerTimeLabel: Double = 46 + /// The chrome device-pill sample-rate readout (D5). Widest legitimate string is a + /// high-res fractional rate — "176.4 kHz" (9 chars); SLOT-02 asserts it fits. + public static let chromeSampleRate: Double = 66 + /// The footer's condensed signal readout ("Enhanced · 176.4 kHz" + the 6pt status dot). + /// Was 120pt, which truncated even "Enhanced · 48 kHz" to "48 k…" the moment the Enhanced + /// path started publishing a real rate (founder screenshot, PR-6 round); SLOT-03 asserts + /// the widest legitimate content fits. + public static let footerSignalSlot: Double = 150 +} diff --git a/Sources/DesignTokenKit/PeakHoldTracker.swift b/Sources/DesignTokenKit/PeakHoldTracker.swift new file mode 100644 index 0000000..f42d542 --- /dev/null +++ b/Sources/DesignTokenKit/PeakHoldTracker.swift @@ -0,0 +1,75 @@ +// PeakHoldTracker — the pure resolver of the analyzer's peak-cap motion tokens over FED +// time (S10.7 PR 3, design §6/§7 R2). Kit-charter placement: `holdSeconds`/`decayPerSecond` +// are motion-design data, and this struct resolves them exactly like `resolveSurface` +// resolves color tokens. TIME-FED by design (the S10.6 monotonic-while-playing lesson): +// no wall clock anywhere — no feed, no decay, so "caps freeze on pause" is structural. + +import Foundation + +// MARK: - Config (motion-design tokens) + +public struct PeakHoldConfig: Sendable, Equatable { + /// How long a latched cap holds before decaying (design §6 PR 3: ~600 ms). + public let holdSeconds: Double + /// Decay slope once the hold expires, in full-scale units per second. + public let decayPerSecond: Double + + public init(holdSeconds: Double = 0.6, decayPerSecond: Double = 1.25) { + self.holdSeconds = holdSeconds + self.decayPerSecond = decayPerSecond + } +} + +// MARK: - Tracker + +public struct PeakHoldTracker: Sendable, Equatable { + public let config: PeakHoldConfig + public private(set) var caps: [Double] + /// Per-band hold time remaining (seconds of FED time) before decay begins. + private var holdRemaining: [Double] + + public init(bandCount: Int, config: PeakHoldConfig = PeakHoldConfig()) { + self.config = config + caps = Array(repeating: 0, count: max(0, bandCount)) + holdRemaining = Array(repeating: 0, count: max(0, bandCount)) + } + + /// Advance by `elapsed` fed-seconds with the current live bars. + /// Semantics (PH-01..09): a rising bar re-latches its cap AND restarts the hold; past the + /// hold, the cap decays linearly; a cap never falls below the live bar or 0; hostile + /// input clamps (negative elapsed → 0; non-finite / out-of-range bars → [0, 1]); a + /// band-count change resizes and resets the new layout. + public mutating func update(bars: [Double], elapsed: Double) { + if bars.count != caps.count { + caps = Array(repeating: 0, count: bars.count) + holdRemaining = Array(repeating: 0, count: bars.count) + } + let step = max(0, elapsed.isFinite ? elapsed : 0) + for index in bars.indices { + let raw = bars[index] + let bar = raw.isFinite ? min(max(raw, 0), 1) : 0 + if bar >= caps[index] { + caps[index] = bar + holdRemaining[index] = config.holdSeconds + continue + } + // Consume the hold first; any spill past it decays the cap linearly. + let spill = step - holdRemaining[index] + if spill <= 0 { + holdRemaining[index] -= step + } else { + holdRemaining[index] = 0 + caps[index] = max(bar, caps[index] - config.decayPerSecond * spill) + } + } + } + + /// Clear to the given live bars (track change / stop); empty means all-zero. + public mutating func reset(to bars: [Double] = []) { + for index in caps.indices { + let bar = index < bars.count ? min(max(bars[index], 0), 1) : 0 + caps[index] = bar.isFinite ? bar : 0 + holdRemaining[index] = 0 + } + } +} diff --git a/Sources/DesignTokenKit/SampledGlow.swift b/Sources/DesignTokenKit/SampledGlow.swift new file mode 100644 index 0000000..047636b --- /dev/null +++ b/Sources/DesignTokenKit/SampledGlow.swift @@ -0,0 +1,143 @@ +// SampledGlow — the D8 art-sampled glow pipeline's PURE half (S10.7 PR 7, design §3.3): +// dominant-color selection over pixel samples, and the clamp that binds every sampled color +// into token-defined bounds — the §3.1 pre-binding: the R4 audit keeps auditing an +// ENUMERABLE worst case (each slot's ceiling-gray corner), so a pathological cover can never +// blow the contrast budget. The app-side `ArtworkGlowSampler` extracts pixels (AppKit) and +// calls THIS for every decision; the render override and the audit fold share these bounds. + +import Foundation + +public enum SampledGlow { + // MARK: Clamp bounds (per glow slot, parallel to `GlowFieldSpec.glows`) + + /// Per-slot sRGB channel ceiling for a SAMPLED glow color. The audit corner — a gray at + /// this ceiling composited at the slot's token alpha — is the admissible worst case: + /// sRGB alpha-compositing and relative luminance are both monotone in every source + /// channel, so no in-box color can composite brighter. The teal slot (top-left, alpha + /// .28, hosts the hero and the queue head) is the tightest; lime/blue run at ~40%/35% + /// of that alpha, so they may sample brighter without moving the worst case. + public static let channelMax: [Double] = [0.62, 0.95, 0.95] + + /// Aesthetic floors — below either, the slot falls back to the BRAND color (audited + /// separately by the concrete R4-GLOW tests): a near-black sample reads as no glow, a + /// near-gray one as a muddy wash. Not audit-relevant (fallback = brand = in budget). + public static let minMaxChannel: Double = 0.22 + public static let minChannelSpread: Double = 0.05 + + /// Pixels below these don't VOTE in the histogram (they carry no usable hue): the + /// value floor drops shadow/letterbox black, the saturation floor drops white/gray. + public static let voteMinValue: Double = 0.15 + public static let voteMinSaturation: Double = 0.12 + + /// Clamp a sampled color into the slot's box: hue-preserving proportional scale-down + /// (never per-channel truncation, which would shift the hue), with the slot's token + /// DARK alpha forced (the glow field is dark-only, and alphas are never sampled — + /// they are the audited quantity). The aesthetic floors are evaluated on the SCALED + /// color — what would actually render (review MINOR-4: a barely-chromatic bright + /// sample whose spread collapses under the scale-down must reject, not render as a + /// sub-floor gray; this also makes the clamp genuinely idempotent). Returns `nil` + /// on rejection — the caller keeps the brand color for that slot. + public static func clampedSampledColor(_ sampled: RGBAColor, slot: Int) -> RGBAColor? { + guard slot >= 0, slot < GlowFieldSpec.glows.count else { return nil } + let maxChannel = max(sampled.red, max(sampled.green, sampled.blue)) + let minChannel = min(sampled.red, min(sampled.green, sampled.blue)) + guard maxChannel > 0 else { return nil } + let ceiling = channelMax[slot] + let scale = maxChannel > ceiling ? ceiling / maxChannel : 1 + let scaledMax = maxChannel * scale + let scaledMin = minChannel * scale + guard scaledMax >= minMaxChannel, scaledMax - scaledMin >= minChannelSpread else { + return nil + } + return RGBAColor(red: sampled.red * scale, + green: sampled.green * scale, + blue: sampled.blue * scale, + alpha: GlowFieldSpec.glows[slot].color.dark.alpha) + } + + /// Each slot's ceiling-gray at its token dark alpha: the worst case of the slot's + /// SAMPLED space (channel monotonicity — no in-box color composites brighter). NOTE + /// the scope (review MAJOR-2): the corner does NOT dominate the slot's BRAND fallback + /// (brand teal's green/blue exceed the 0.62 teal ceiling; brand blue's blue channel + /// exceeds its 0.95 corner), so the reachable space is only covered by folding every + /// per-slot {corner, brand} combination — which is exactly what R4-GLOW-D8 does. + public static var auditCornerPalette: [RGBAColor?] { + GlowFieldSpec.glows.indices.map { slot in + RGBAColor.gray(channelMax[slot], + alpha: GlowFieldSpec.glows[slot].color.dark.alpha) + } + } + + // MARK: Dominant-color selection (pure, deterministic) + + /// Hue-bucket histogram over pixel samples → up to `GlowFieldSpec.glows.count` dominant + /// chromatic colors, strongest first (weight = saturation × value, so a small vivid + /// accent can out-vote a large dull field). Colors are the weighted per-bucket averages. + /// A cover with no chromatic pixels yields `[]` (every slot → brand). Deterministic: + /// ties break toward the lower bucket index. + public static func dominantColors(samples: [RGBAColor]) -> [RGBAColor] { + let bucketCount = 12 + var weight = [Double](repeating: 0, count: bucketCount) + var sumRed = [Double](repeating: 0, count: bucketCount) + var sumGreen = [Double](repeating: 0, count: bucketCount) + var sumBlue = [Double](repeating: 0, count: bucketCount) + + for pixel in samples { + let maxChannel = max(pixel.red, max(pixel.green, pixel.blue)) + let minChannel = min(pixel.red, min(pixel.green, pixel.blue)) + let value = maxChannel + let saturation = maxChannel > 0 ? (maxChannel - minChannel) / maxChannel : 0 + guard value >= voteMinValue, saturation >= voteMinSaturation else { continue } + let bucket = min(Int(hueFraction(pixel) * Double(bucketCount)), bucketCount - 1) + let vote = saturation * value + weight[bucket] += vote + sumRed[bucket] += pixel.red * vote + sumGreen[bucket] += pixel.green * vote + sumBlue[bucket] += pixel.blue * vote + } + + return weight.indices + .filter { weight[$0] > 0 } + .sorted { weight[$0] == weight[$1] ? $0 < $1 : weight[$0] > weight[$1] } + .prefix(GlowFieldSpec.glows.count) + .map { bucket in + RGBAColor(red: sumRed[bucket] / weight[bucket], + green: sumGreen[bucket] / weight[bucket], + blue: sumBlue[bucket] / weight[bucket]) + } + } + + /// Standard HSV hue as a fraction in [0, 1). Callers guarantee the pixel is chromatic + /// (spread > 0); a defensive 0 is returned for the gray case anyway. + private static func hueFraction(_ pixel: RGBAColor) -> Double { + let maxChannel = max(pixel.red, max(pixel.green, pixel.blue)) + let minChannel = min(pixel.red, min(pixel.green, pixel.blue)) + let spread = maxChannel - minChannel + guard spread > 0 else { return 0 } + let hueSixth: Double = if maxChannel == pixel.red { + ((pixel.green - pixel.blue) / spread).truncatingRemainder(dividingBy: 6) + } else if maxChannel == pixel.green { + (pixel.blue - pixel.red) / spread + 2 + } else { + (pixel.red - pixel.green) / spread + 4 + } + let hue = hueSixth / 6 + return hue < 0 ? hue + 1 : hue + } +} + +// MARK: - Pixel ingestion + +public extension RGBAColor { + /// A pixel sample from 8-bit RGBA image bytes (the app-side extractor's entry into the + /// Kit's color space — keeps raw component construction out of the app target). The + /// sampler's context is PREMULTIPLIED-alpha; the alpha byte is deliberately ignored: + /// fully transparent margins premultiply to ~black and are dropped by the vote value + /// floor, and partial alpha scales all channels uniformly — hue preserved, vote weight + /// naturally reduced. + static func fromPixel(red: UInt8, green: UInt8, blue: UInt8) -> RGBAColor { + RGBAColor(red: Double(red) / 255.0, + green: Double(green) / 255.0, + blue: Double(blue) / 255.0) + } +} diff --git a/Sources/DesignTokenKit/SurfaceResolver.swift b/Sources/DesignTokenKit/SurfaceResolver.swift new file mode 100644 index 0000000..9d32440 --- /dev/null +++ b/Sources/DesignTokenKit/SurfaceResolver.swift @@ -0,0 +1,103 @@ +// SurfaceResolver — the pure RT/IC resolution contract for glass-look surfaces (S10.7 R0). +// +// The app-side `.glassPanel(_:in:)` modifier is a thin shim: it reads the accessibility +// environment, calls `resolveSurface`, and paints the result. ALL resolution logic lives +// here so it is unit-testable headlessly (design §7 R2: RES-01..04). Roles land STAGED with +// their first consumer (design §3.2): PR 1a ships `.overlay` only; the fill roles (panel / +// lens / control / badge) arrive with PRs 2–6 and will extend `ResolvedSurface` with the +// translucent-fill → opaque-fill Reduce-Transparency swap. + +import Foundation + +// MARK: - Roles + +/// What a surface IS (never how it looks). The app-wide role charter is in the design doc; +/// cases appear here only once a consumer exists (hostile-Periphery staging rule). +public enum SurfaceRole: Equatable, Sendable { + /// A transient floating surface with variable content genuinely beneath it — the ONE + /// Material-backed role (design §3.1: banner / toast / EQ recall / selection pill). + /// The substrate is token-governed but per-site (the S10.3 pill shipped on `.bar`); + /// the shape is a call-site parameter on the app-side modifier. + case overlay(OverlaySubstrate) + /// The analyzer lens — the first Regime-B FILL role (PR 3): a token'd translucent fill + /// + edge decoration; NEVER backdrop-sampling (design §3.1). + case lens + /// Hero badge capsules (PR 4): the 8a small-control fill, same RT/IC contract as lens. + case badge + /// The inspector column (PR 5): the 8a panel fill, same RT/IC contract. + case panel +} + +/// The animation-gate predicate (design §3.4/§7 R2 PG-01..04): the pulsing dot and the +/// active-row equalizer animate ONLY while playing AND only when Reduce Motion is off. +public func pulseIsActive(isPlaying: Bool, reduceMotion: Bool) -> Bool { + isPlaying && !reduceMotion +} + +/// The blessed system-material substrates for `.overlay`. `.bar` exists solely for the +/// pre-glass selection pill's shipped look; PR 5 / S10.8 restyles may unify onto `.ultraThin`. +public enum OverlaySubstrate: Equatable, Sendable, CaseIterable { + case ultraThin + case bar +} + +// MARK: - Resolution result + +/// What the modifier paints. `.systemMaterial` = the system owns the fallback behavior +/// (Reduce Transparency / Increase Contrast adaptation is NATIVE to Material — the resolver +/// deliberately passes it through untouched, design §3.2 `.overlay` spec). `.fill` = a +/// Regime-B token fill, already RT/IC-resolved (translucent normally; the OPAQUE +/// fill-over-window composite when transparency is reduced — derived, never hand-kept). +public enum ResolvedSurface: Equatable, Sendable { + case systemMaterial(OverlaySubstrate) + case fill(RGBAColor) +} + +// MARK: - Resolver + +/// Pure resolution: role × appearance × accessibility flags → what to paint. Deterministic, +/// total. Contract notes the fill roles will honor (asserted then by RES-01/02): a +/// translucent fill goes opaque when `reduceTransparency` is true, AND when +/// `increasedContrast` is true even with `reduceTransparency` false — macOS couples IC→RT at +/// the OS level, but the resolver must never depend on the OS doing it (design §7 RES-02). +public func resolveSurface(role: SurfaceRole, + appearance: TokenAppearance, + reduceTransparency: Bool, + increasedContrast: Bool) -> ResolvedSurface { + switch role { + case let .overlay(substrate): + // Native-adaptation ownership: Material self-adapts to RT/IC/appearance, so the + // resolver returns the substrate unconditionally — asserted for the full flag + // cube in RES tests. + return .systemMaterial(substrate) + // Every fill role names its pair EXPLICITLY (no `default:`) so a future role cannot + // silently inherit panelFill — it fails to compile until someone binds its token here. + case .lens: + return resolvedFill(Palette.lensFill, appearance: appearance, + reduceTransparency: reduceTransparency, + increasedContrast: increasedContrast) + case .badge: + return resolvedFill(Palette.badgeFill, appearance: appearance, + reduceTransparency: reduceTransparency, + increasedContrast: increasedContrast) + case .panel: + return resolvedFill(Palette.panelFill, appearance: appearance, + reduceTransparency: reduceTransparency, + increasedContrast: increasedContrast) + } +} + +/// RES-01/02 for every fill role: translucent normally; opaque (fill composited over the +/// window) when transparency is reduced — and under Increase Contrast EVEN IF the RT flag +/// is false (never depend on the OS coupling IC→RT). +private func resolvedFill(_ pair: AppearancePair, + appearance: TokenAppearance, + reduceTransparency: Bool, + increasedContrast: Bool) -> ResolvedSurface { + let fill = pair.value(for: appearance, increasedContrast: increasedContrast) + if reduceTransparency || increasedContrast { + let window = Palette.window.value(for: appearance, increasedContrast: increasedContrast) + return .fill(fill.over(window)) + } + return .fill(fill) +} diff --git a/Sources/DesignTokenKit/TokenColor.swift b/Sources/DesignTokenKit/TokenColor.swift new file mode 100644 index 0000000..e1c2dc1 --- /dev/null +++ b/Sources/DesignTokenKit/TokenColor.swift @@ -0,0 +1,112 @@ +// DesignTokenKit — the app's design-token DATA and their pure resolvers (S10.7 R0). +// +// Admission charter (s10-7-liquid-glass-design.md §3.2): token data (color pairs, radii, +// decoration constants, slot widths) and PURE resolvers over appearance, accessibility flags, +// and time. NOTHING here may import SwiftUI/AppKit (a strict-gate purity guard enforces it) — +// that is what makes every claim about the visual system a headless, forever-green test +// instead of an eyeball. The app-side `DesignSystemGlass.swift` bridges this data into +// SwiftUI values; no RGBA value may exist both here and there (single-source invariant). + +import Foundation + +// MARK: - RGBA color (plain data) + +/// A straight-alpha sRGB color. Components are in [0, 1]; `alpha` < 1 marks a translucent +/// token (composited by `over(_:)` exactly the way the audit needs). +public struct RGBAColor: Sendable, Equatable { + public let red: Double + public let green: Double + public let blue: Double + public let alpha: Double + + public init(red: Double, green: Double, blue: Double, alpha: Double = 1.0) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } + + /// Grayscale convenience — `white` is the gray level. + public static func gray(_ white: Double, alpha: Double = 1.0) -> RGBAColor { + RGBAColor(red: white, green: white, blue: white, alpha: alpha) + } + + /// A copy with a different alpha (token derivations like the accent row tints). + public func opacity(_ alpha: Double) -> RGBAColor { + RGBAColor(red: red, green: green, blue: blue, alpha: alpha) + } +} + +// MARK: - Compositing + WCAG math (pure; the R4 audit's engine) + +public extension RGBAColor { + /// Straight-alpha "source over" compositing in sRGB space (the standard audit + /// approximation: blending happens in gamma space, matching what AppKit does for + /// plain translucent fills). Result is opaque when the backdrop is opaque. + func over(_ backdrop: RGBAColor) -> RGBAColor { + let outAlpha = alpha + backdrop.alpha * (1 - alpha) + guard outAlpha > 0 else { return RGBAColor(red: 0, green: 0, blue: 0, alpha: 0) } + func channel(_ fg: Double, _ bg: Double) -> Double { + (fg * alpha + bg * backdrop.alpha * (1 - alpha)) / outAlpha + } + return RGBAColor(red: channel(red, backdrop.red), + green: channel(green, backdrop.green), + blue: channel(blue, backdrop.blue), + alpha: outAlpha) + } + + /// WCAG 2.x relative luminance (sRGB linearization). Defined for opaque colors; + /// composite translucent tokens onto their backdrop first. + var relativeLuminance: Double { + func linear(_ channel: Double) -> Double { + channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue) + } + + /// WCAG contrast ratio between two OPAQUE colors (≥ 4.5 = AA text, ≥ 3.0 = non-text). + static func contrastRatio(_ first: RGBAColor, _ second: RGBAColor) -> Double { + let lighter = max(first.relativeLuminance, second.relativeLuminance) + let darker = min(first.relativeLuminance, second.relativeLuminance) + return (lighter + 0.05) / (darker + 0.05) + } +} + +// MARK: - Appearance pair + +/// One token = one light value + one dark value (S9-T posture: appearance is owned by the +/// token layer, never by views). Increased-contrast variants default to the base values so +/// tokens gain them incrementally without touching call sites. +public struct AppearancePair: Sendable, Equatable { + public let light: RGBAColor + public let dark: RGBAColor + public let lightHighContrast: RGBAColor + public let darkHighContrast: RGBAColor + + public init(light: RGBAColor, dark: RGBAColor, + lightHighContrast: RGBAColor? = nil, darkHighContrast: RGBAColor? = nil) { + self.light = light + self.dark = dark + self.lightHighContrast = lightHighContrast ?? light + self.darkHighContrast = darkHighContrast ?? dark + } + + /// Appearance-independent token (accent family): the same value on both sides. + public init(both value: RGBAColor) { + self.init(light: value, dark: value) + } + + public func value(for appearance: TokenAppearance, increasedContrast: Bool = false) -> RGBAColor { + switch (appearance, increasedContrast) { + case (.light, false): light + case (.dark, false): dark + case (.light, true): lightHighContrast + case (.dark, true): darkHighContrast + } + } +} + +/// The two system appearances a token resolves against. +public enum TokenAppearance: CaseIterable, Sendable { + case light, dark +} diff --git a/Sources/LibraryBrowseKit/FacetTextFilter.swift b/Sources/LibraryBrowseKit/FacetTextFilter.swift index 93076a0..c3d62fd 100644 --- a/Sources/LibraryBrowseKit/FacetTextFilter.swift +++ b/Sources/LibraryBrowseKit/FacetTextFilter.swift @@ -3,15 +3,17 @@ import Foundation // MARK: - FacetTextFilter (S9.6 — in-place, in-memory list filter) /// The pure decision behind the per-section Filter field: does a facet row match the filter text? -/// A case-insensitive substring match over one or more candidate strings (e.g. an album matches on -/// its title OR its artist). An empty/whitespace query matches everything (filter off). This mirrors -/// Apple Music's Filter field — "a straight lexical, case-independent match" that narrows the current -/// list in place — NOT the FTS catalog search the Songs tab uses. +/// A case- AND diacritic-insensitive substring match ("beyonce" matches "Beyoncé" — the S10.7 §5 +/// queue-filter contract, via `localizedStandardContains`, Apple's user-facing-search comparison) +/// over one or more candidate strings (e.g. an album matches on its title OR its artist). An +/// empty/whitespace query matches everything (filter off). This mirrors Apple Music's Filter +/// field — a lexical match that narrows the current list in place — NOT the FTS catalog search +/// the Songs tab uses. public enum FacetTextFilter { public static func matches(_ candidates: [String], query: String) -> Bool { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return true } - return candidates.contains { $0.localizedCaseInsensitiveContains(trimmed) } + return candidates.contains { $0.localizedStandardContains(trimmed) } } /// Convenience for a single candidate (Artists / Genres filter on their name). diff --git a/Tests/DesignTokenKitTests/ContrastAuditTests.swift b/Tests/DesignTokenKitTests/ContrastAuditTests.swift new file mode 100644 index 0000000..fcbfaf3 --- /dev/null +++ b/Tests/DesignTokenKitTests/ContrastAuditTests.swift @@ -0,0 +1,486 @@ +// R4 — the PERMANENT contrast audit (design §7 R4): pure sRGB compositing + WCAG math over +// Kit token data, so "the palette is legible" is a forever-green `swift test` fact, not a +// one-off audit. PR 1a scope = the LEGACY pairs (window/card/panel × label hierarchy — the +// D10 net: when PR 2 re-bases the dark stack, these pairs re-verify the untouched tabs by +// math). Glow/lens/panel-role composites join in PRs 2–5 per the §7 R4 pair table. +// +// Thresholds are the WCAG constants, never tuned: ≥ 4.5:1 text AA; ≥ 3.0:1 non-text. + +import DesignTokenKit +import Testing + +@Suite("Contrast audit — legacy surfaces (R4)") +struct ContrastAuditTests { + /// AA threshold for text (WCAG 1.4.3). + static let textAA = 4.5 + /// Non-text contrast threshold (WCAG 1.4.11) — meter fills, indicator bars. + static let nonTextAA = 3.0 + + /// The surfaces labels sit on today: the window itself, and card/panel composited over + /// the window (they are translucent in dark mode — compositing IS the audit's point). + private static func surfaces(_ appearance: TokenAppearance) -> [(name: String, color: RGBAColor)] { + let window = Palette.window.value(for: appearance) + return [ + ("window", window), + ("card⊕window", Palette.card.value(for: appearance).over(window)), + ("panel⊕window", Palette.panel.value(for: appearance).over(window)), + ] + } + + /// Effective text color = the (translucent) label composited onto its surface; the + /// ratio is then between two opaque colors, per WCAG method. + static func ratio(label: AppearancePair, on surface: RGBAColor, + _ appearance: TokenAppearance) -> Double { + let text = label.value(for: appearance).over(surface) + return RGBAColor.contrastRatio(text, surface) + } + + @Test("R4-LEG-01: label + labelSecondary clear AA on window/card/panel, both appearances") + func primaryAndSecondaryLabels() { + for appearance in TokenAppearance.allCases { + for surface in Self.surfaces(appearance) { + for (name, label) in [("label", Palette.label), + ("labelSecondary", Palette.labelSecondary)] { + let ratio = Self.ratio(label: label, on: surface.color, appearance) + #expect(ratio >= Self.textAA, + "\(name) on \(surface.name) (\(appearance)) = \(ratio) < \(Self.textAA)") + } + } + } + } + + /// The S9 audit lifted tertiary for this — but it measured against the WINDOW, not the + /// translucent card/panel COMPOSITES. The audit's first real find (PR 1a, 2026-07-17): + /// tertiary on panel⊕window at the OLD #1E1E1E base was 4.4601:1. The D10 deep-base + /// re-tune (PR 2) fixed it — the pin flipped "unexpectedly passed" on the first run + /// against #0e1013 and was PROMOTED to the hard assertion below (the design §7 R4 + /// mechanism working as written). + @Test("R4-LEG-02: labelTertiary clears AA on window/card/panel, both appearances") + func tertiaryLabel() { + for appearance in TokenAppearance.allCases { + for surface in Self.surfaces(appearance) { + let ratio = Self.ratio(label: Palette.labelTertiary, on: surface.color, appearance) + #expect(ratio >= Self.textAA, + "labelTertiary on \(surface.name) (\(appearance)) = \(ratio) < \(Self.textAA)") + } + } + } + + /// PR 6 (founder "split text vs fill", retires the light pins): the `status*Text` variants + /// carry every text/glyph site (WCAG 4.5:1) — their LIGHT values are the dark red/amber + /// that clear AA by design; DARK == the vivid value (already AA on the deep base). The + /// vivid `statusError` is the meter-hot FILL (3:1 non-text — the "CLIP" word carries the + /// meaning, A-M5, so the bar only needs to be visible). The vivid `statusWarning` is used + /// ONLY as the decorative status dot (reinforcing adjacent text), so it is deliberately + /// NOT audited at 3:1 here (it does not clear it on light, by design — the text beside it + /// carries the meaning). History: the dark pairs promoted to hard assertions when the D10 + /// deep base (PR 2) lifted them; the old light-fails-AA pins are gone because vivid-as-text + /// is gone. + @Test("R4-LEG-03: status text variants clear AA + the error fill clears non-text, all surfaces") + func statusLegibility() { + let textTokens: [(name: String, pair: AppearancePair)] = [ + ("statusErrorText", Palette.statusErrorText), + ("statusWarningText", Palette.statusWarningText), + ] + for appearance in TokenAppearance.allCases { + for surface in Self.surfaces(appearance) { + for token in textTokens { + let text = token.pair.value(for: appearance).over(surface.color) + #expect(RGBAColor.contrastRatio(text, surface.color) >= Self.textAA, + "\(token.name) on \(surface.name) (\(appearance)) < \(Self.textAA)") + } + let fill = Palette.statusError.value(for: appearance).over(surface.color) + #expect(RGBAColor.contrastRatio(fill, surface.color) >= Self.nonTextAA, + "statusError fill on \(surface.name) (\(appearance)) < \(Self.nonTextAA)") + } + } + } + + /// Design §7 R4 pair 4 — owed since PR 6, break-it caught the gap: the chrome device + /// pill is a `.badge` fill over the WINDOW (the chrome band keeps the plain base, D4); + /// its name is `label`, its rate readout `labelSecondary`. Both appearances, plus the + /// RT/IC opaque composite the resolver serves. + @Test("R4-CONTROL-01: device-pill text clears AA on the badge fill over the chrome band") + func devicePillText() { + for appearance in TokenAppearance.allCases { + let window = Palette.window.value(for: appearance) + let translucent = Palette.badgeFill.value(for: appearance).over(window) + let opaque = Palette.badgeFill.value(for: appearance, increasedContrast: true).over(window) + for (name, label) in [("label", Palette.label), + ("labelSecondary", Palette.labelSecondary)] { + for surface in [translucent, opaque] { + let ratio = Self.ratio(label: label, on: surface, appearance) + #expect(ratio >= Self.textAA, "\(name) on pill (\(appearance)) = \(ratio)") + } + } + } + } + + // MARK: Glow-field composites (PR 2 — §7 R4 pairs 1 + 6) + + // GEOMETRIC MODEL (v2, per the PR-2 design review "Option B"): the audit SAMPLES the + // REAL three-glow composite from `GlowFieldSpec.compositeBackdrop` on a grid, at the + // reference tab geometry AND the min-window geometry (overlaps grow as the container + // shrinks). This replaced an abstract cores+overlaps model whose worst cases were + // geometrically impossible — and it re-verifies AUTOMATICALLY whenever the founder + // tunes glow centers, because render and audit read the same Kit data. + // + // DARK ONLY: the resolver suppresses the glow field outside dark appearance (RES-04), + // so light pairs would audit pixels that never render. + // + // PLACEMENT RULE (§3.3, unchanged): labelTertiary small text never sits inside the + // TEAL core (t ≤ midStop) — the 8a mock puts only the HERO (large text, 3:1) there. + // Full-alpha teal × tertiary measures 3.98:1 — the number that forced the rule. + + /// Tab geometries to sample: (reference 1120×596, min-window 880×516 content region). + static let glowGeometries: [(width: Double, height: Double)] = [(1120, 596), (880, 516)] + + /// Grid resolution — fine enough that a core (~250pt across) spans many samples. + private static let gridColumns = 24 + private static let gridRows = 16 + + static func gridPoints() -> [(x: Double, y: Double)] { + (0 ... gridRows).flatMap { row in + (0 ... gridColumns).map { column in + (Double(column) / Double(gridColumns), Double(row) / Double(gridRows)) + } + } + } + + /// Pair 1a: label + labelSecondary clear AA at EVERY sampled point of the real glow + /// field — no placement restriction on primary/secondary text. + @Test("R4-GLOW-01: label + labelSecondary clear AA across the sampled glow field (dark)") + func primaryLabelsOnGlowField() { + for geometry in Self.glowGeometries { + for point in Self.gridPoints() { + let backdrop = GlowFieldSpec.compositeBackdrop( + unitX: point.x, unitY: point.y, + containerWidth: geometry.width, containerHeight: geometry.height, + appearance: .dark + ) + for (name, label) in [("label", Palette.label), + ("labelSecondary", Palette.labelSecondary)] { + let ratio = Self.ratio(label: label, on: backdrop, .dark) + #expect(ratio >= Self.textAA, + "\(name) @(\(point.x),\(point.y)) \(geometry.width)pt = \(ratio)") + } + } + } + } + + /// Pair 1b: labelTertiary clears AA at every sampled point OUTSIDE the teal core (its + /// placement rule's whole allowed domain). + @Test("R4-GLOW-04: labelTertiary clears AA everywhere outside the teal core (dark)") + func tertiaryOnGlowField() { + for geometry in Self.glowGeometries { + for point in Self.gridPoints() { + let tealT = GlowFieldSpec.tealDistance(unitX: point.x, unitY: point.y, + containerWidth: geometry.width, + containerHeight: geometry.height) + guard tealT > GlowFieldSpec.falloffMidStop else { continue } + let backdrop = GlowFieldSpec.compositeBackdrop( + unitX: point.x, unitY: point.y, + containerWidth: geometry.width, containerHeight: geometry.height, + appearance: .dark + ) + let ratio = Self.ratio(label: Palette.labelTertiary, on: backdrop, .dark) + #expect(ratio >= Self.textAA, + "labelTertiary @(\(point.x),\(point.y)) \(geometry.width)pt = \(ratio)") + } + } + } + + /// Pair 6: queue-row tints over the glow field. The queue occupies the RIGHT region + /// (today's 50/50 pane; post-PR-5 queue-flex) — never the top-left teal core. + /// Domain corrected at the break-it round: the original `x >= 0.5` guard modeled the + /// pre-PR-5 50/50 split, but the restructure moved the queue to the LEFT flex region — + /// where the teal core reaches — and jump-to-now-playing can center a tinted row + /// anywhere in it. Rows are audited at EVERY grid point (a superset of reachable row + /// positions — over-auditing is free, under-auditing was the hole). + @Test("R4-GLOW-02: label clears AA on row tints over the queue region's glow field (dark)") + func labelsOnRowTintsOverGlow() { + for geometry in Self.glowGeometries { + for point in Self.gridPoints() { + let backdrop = GlowFieldSpec.compositeBackdrop( + unitX: point.x, unitY: point.y, + containerWidth: geometry.width, containerHeight: geometry.height, + appearance: .dark + ) + for (name, tint) in [("rowNowPlaying", Palette.rowNowPlaying), + ("rowSelected", Palette.rowSelected)] { + let tinted = tint.value(for: .dark).over(backdrop) + let ratio = Self.ratio(label: Palette.label, on: tinted, .dark) + #expect(ratio >= Self.textAA, + "label on \(name) @(\(point.x),\(point.y)) \(geometry.width)pt = \(ratio)") + } + } + } + } + + // R4-GLOW-05 (card⊕glow, one pinned tertiary defect) was RETIRED in PR 5: the queue + // pane's card fill is gone — rows sit directly on the glow field, which R4-GLOW-02 + // audits. The pin resolved by construction, not by waiver. + + // R4-GLOW-D8 (the sampled-palette corner audit) lives in its OWN suite at the bottom of + // this file — the base suite sits at the type-body-length limit; the D8 suite shares + // the fileprivate grid/ratio helpers so both audit the same geometry. + + // MARK: Lens composites (PR 3 — §7 R4 pair 2) + + /// The lens fill sits over the glow field; its future axis text (PR 5) and any in-lens + /// labels must clear AA on the fill⊕glow composite — sampled across the field in dark + /// (the lens is currently full-width; the D6 frame narrows it in PR 5, a subset of + /// these points). Light: the lens is white-glass over the plain light window. RT/IC: + /// the resolver's OPAQUE fallback is audited explicitly. + @Test("R4-LENS-01: label hierarchy clears AA on the lens fill over its real backdrops") + func labelsOnLens() { + // Dark: lens ⊕ glow-field composite, sampled. + for geometry in Self.glowGeometries { + for point in Self.gridPoints() { + let glow = GlowFieldSpec.compositeBackdrop( + unitX: point.x, unitY: point.y, + containerWidth: geometry.width, containerHeight: geometry.height, + appearance: .dark + ) + let lens = Palette.lensFill.dark.over(glow) + for (name, label) in [("label", Palette.label), + ("labelSecondary", Palette.labelSecondary)] { + let ratio = Self.ratio(label: label, on: lens, .dark) + #expect(ratio >= Self.textAA, + "\(name) on lens⊕glow @(\(point.x),\(point.y)) = \(ratio)") + } + } + } + // Light: lens over the plain window (glows are suppressed in light). + let lightLens = Palette.lensFill.light.over(Palette.window.light) + for (name, label) in [("label", Palette.label), ("labelSecondary", Palette.labelSecondary)] { + let ratio = Self.ratio(label: label, on: lightLens, .light) + #expect(ratio >= Self.textAA, "\(name) on light lens = \(ratio)") + } + // RT/IC opaque fallbacks, both appearances. + for appearance in TokenAppearance.allCases { + let opaque = Palette.lensFill.value(for: appearance) + .over(Palette.window.value(for: appearance)) + for (name, label) in [("label", Palette.label), ("labelSecondary", Palette.labelSecondary)] { + let ratio = Self.ratio(label: label, on: opaque, appearance) + #expect(ratio >= Self.textAA, "\(name) on opaque lens (\(appearance)) = \(ratio)") + } + } + } + + // MARK: Hero badge composites (PR 4 — §7 R4 pair 5) + + /// Badge capsules sit on the glow field (the hero region — the TEAL core included, so + /// this is the field's brightest text seat). RULE (the audit's third real catch, + /// 2026-07-17): badge text is the PRIMARY `label` or a status color, NEVER the dimmed + /// hierarchy — `labelSecondary` on badge⊕teal-core measures 4.26:1 (22 sampled points + /// fail); the white badge fill brightens the backdrop exactly like the card⊕lime case. + /// Hierarchy on a chip comes from the capsule, not from dimming its text. + @Test("R4-BADGE-01: badge text clears AA on the badge fill over its real backdrops") + func badgeTextOnBadges() { + // PR 6 split: badge text is `label` or `statusWarningText` (the AA variant) — the + // vivid `statusWarning` is only the path DOT (non-text, decorative reinforcement of + // the adjacent label, so not audited at 3:1). This retires the light-badge pin. + let texts: [(String, AppearancePair)] = [ + ("label", Palette.label), + ("statusWarningText", Palette.statusWarningText), + ] + // Dark: badge ⊕ glow-field composite, sampled across the field. + for geometry in Self.glowGeometries { + for point in Self.gridPoints() { + let glow = GlowFieldSpec.compositeBackdrop( + unitX: point.x, unitY: point.y, + containerWidth: geometry.width, containerHeight: geometry.height, + appearance: .dark + ) + let badge = Palette.badgeFill.dark.over(glow) + for (name, text) in texts { + let ratio = Self.ratio(label: text, on: badge, .dark) + #expect(ratio >= Self.textAA, + "\(name) on badge⊕glow @(\(point.x),\(point.y)) = \(ratio)") + } + } + } + // Light badge: both text colors clear AA outright now (statusWarningText's light value + // is the dark amber sized for exactly this backdrop) — the pin is retired. + let lightBadge = Palette.badgeFill.light.over(Palette.window.light) + for (name, text) in texts { + let ratio = Self.ratio(label: text, on: lightBadge, .light) + #expect(ratio >= Self.textAA, "\(name) on light badge = \(ratio)") + } + for appearance in TokenAppearance.allCases { + let opaque = Palette.badgeFill.value(for: appearance) + .over(Palette.window.value(for: appearance)) + let ratio = Self.ratio(label: Palette.label, on: opaque, appearance) + #expect(ratio >= Self.textAA, "label on opaque badge (\(appearance)) = \(ratio)") + } + } + + // MARK: Inspector panel composites (PR 5 — §7 R4 pair 3) + + /// The inspector fill sits over the glow field on the RIGHT side (never the teal core — + /// the §3.3 placement rule's allowed domain), so tertiary is audited here too: the + /// column is tertiary text's designed home. + @Test("R4-PANEL-01: label hierarchy clears AA on the panel fill over its real backdrops") + func labelsOnPanel() { + let labels: [(String, AppearancePair)] = [ + ("label", Palette.label), ("labelSecondary", Palette.labelSecondary), + ("labelTertiary", Palette.labelTertiary), + ] + for geometry in Self.glowGeometries { + for point in Self.gridPoints() where point.x >= 0.5 { + let glow = GlowFieldSpec.compositeBackdrop( + unitX: point.x, unitY: point.y, + containerWidth: geometry.width, containerHeight: geometry.height, + appearance: .dark + ) + let panel = Palette.panelFill.dark.over(glow) + for (name, label) in labels { + let ratio = Self.ratio(label: label, on: panel, .dark) + #expect(ratio >= Self.textAA, + "\(name) on panel⊕glow @(\(point.x),\(point.y)) = \(ratio)") + } + } + } + for appearance in TokenAppearance.allCases { + let opaque = Palette.panelFill.value(for: appearance) + .over(Palette.window.value(for: appearance)) + let lightDirect = appearance == .light + ? Palette.panelFill.light.over(Palette.window.light) : opaque + for (name, label) in labels { + #expect(Self.ratio(label: label, on: opaque, appearance) >= Self.textAA, + "\(name) on opaque panel (\(appearance))") + #expect(Self.ratio(label: label, on: lightDirect, appearance) >= Self.textAA, + "\(name) on light panel") + } + } + } +} + +// MARK: - Layout arithmetic (PR 5 — §7.1: the §5 width/height budget as assertions) + +@Suite("Now Playing layout arithmetic (LAY)") +struct LayoutArithmeticTests { + @Test("LAY-01: at the 880pt window minimum, queue and hero-left keep their minimum widths") + func widthBudget() { + let contentWidth = NowPlayingLayout.windowMinWidth - 2 * NowPlayingLayout.contentInset + let queueWidth = contentWidth - NowPlayingLayout.regionGap - NowPlayingLayout.inspectorWidth + #expect(queueWidth >= NowPlayingLayout.queueMinWidth, + "queue gets \(queueWidth)pt at the minimum window") + let heroLeft = contentWidth - NowPlayingLayout.regionGap - NowPlayingLayout.lensMinWidth + #expect(heroLeft >= NowPlayingLayout.heroTextMinWidth, + "hero text gets \(heroLeft)pt at the minimum window") + #expect(NowPlayingLayout.lensMaxWidth >= NowPlayingLayout.lensMinWidth) + } + + @Test("LAY-02: the hero row never starves the queue at the minimum window, max type included") + func heightBudget() { + let content = NowPlayingLayout.windowMinHeight + - NowPlayingLayout.chromeHeight - NowPlayingLayout.footerHeight + // Hero row height = the lens (its fixed height dominates the text block at default + // type) + vertical padding; the type headroom factor absorbs Dynamic-Type growth of + // the title/badge block past the lens height (§7.1 documented approximation). + let heroRow = (NowPlayingLayout.lensHeight + NowPlayingLayout.contentInset + 12) + * NowPlayingLayout.maxTypeHeadroom + let queueRegion = content - heroRow - NowPlayingLayout.contentInset + let minVisibleRows = 5.0 + let rowHeight = 36.0 // SongsList.rowHeight-class row + #expect(queueRegion >= minVisibleRows * rowHeight, + "queue region \(queueRegion)pt < \(minVisibleRows) rows at max type") + } +} + +// MARK: - D8 sampled-corner audit (own suite — the base suite is at the type-length limit) + +/// D8 (PR 7): the art-sampled worst case. Every sampled color is clamped into a per-slot +/// channel-ceiling box with the slot's token alpha forced. PER SLOT, the box corner +/// (ceiling-gray at token alpha) dominates every SAMPLED color — sRGB compositing and +/// relative luminance are monotone in source channels — and the slot's other reachable +/// state is the BRAND fallback, which the corner does NOT dominate (review MAJOR-2: brand +/// teal's green/blue exceed the 0.62 teal ceiling; brand blue's blue channel exceeds its +/// 0.95 corner). So the audit folds EVERY per-slot {corner, brand} combination — 2³ masks — +/// which exactly covers the reachable palette union by per-slot monotonicity. Pairs match +/// R4-GLOW-01/02/04 plus the lens/badge/panel composites that sit over the field (§7 R4 +/// table). The all-brand mask duplicates the concrete R4-GLOW tests — kept for the +/// lattice's completeness argument. +@Suite("Contrast audit — sampled glow corner (R4-GLOW-D8)") +struct SampledCornerAuditTests { + @Test("R4-GLOW-D8: every {corner, brand} fallback-lattice fold clears every pair (dark)") + func sampledCornerLatticeOnGlowField() { + let corner = SampledGlow.auditCornerPalette + let slotCount = GlowFieldSpec.glows.count + for mask in 0 ..< (1 << slotCount) { + let palette: [RGBAColor?] = (0 ..< slotCount).map { slot in + (mask & (1 << slot)) == 0 ? corner[slot] : nil // nil ⇒ the brand token + } + for geometry in ContrastAuditTests.glowGeometries { + for point in ContrastAuditTests.gridPoints() { + let backdrop = GlowFieldSpec.compositeBackdrop( + unitX: point.x, unitY: point.y, + containerWidth: geometry.width, containerHeight: geometry.height, + appearance: .dark, overrideColors: palette + ) + assertPairs(on: backdrop, point: point, geometry: geometry) + } + } + } + } + + private func assertPairs(on backdrop: RGBAColor, point: (x: Double, y: Double), + geometry: (width: Double, height: Double)) { + let textAA = ContrastAuditTests.textAA + // R4-GLOW-01 pairs: label + secondary everywhere. + for (name, label) in [("label", Palette.label), + ("labelSecondary", Palette.labelSecondary)] { + let ratio = ContrastAuditTests.ratio(label: label, on: backdrop, .dark) + #expect(ratio >= textAA, "\(name) @(\(point.x),\(point.y)) \(geometry.width)pt = \(ratio)") + } + // R4-GLOW-04 pair: tertiary outside the teal core (same placement rule). + let tealT = GlowFieldSpec.tealDistance(unitX: point.x, unitY: point.y, + containerWidth: geometry.width, + containerHeight: geometry.height) + if tealT > GlowFieldSpec.falloffMidStop { + let ratio = ContrastAuditTests.ratio(label: Palette.labelTertiary, on: backdrop, .dark) + #expect(ratio >= textAA, + "labelTertiary @(\(point.x),\(point.y)) \(geometry.width)pt = \(ratio)") + } + // R4-GLOW-02 pairs: row tints — EVERY point (post-PR-5 the queue is the LEFT flex + // region, reaching the teal core; the old x >= 0.5 guard was the audit's stale hole). + for (name, tint) in [("rowNowPlaying", Palette.rowNowPlaying), + ("rowSelected", Palette.rowSelected)] { + let tinted = tint.dark.over(backdrop) + let ratio = ContrastAuditTests.ratio(label: Palette.label, on: tinted, .dark) + #expect(ratio >= textAA, "label on \(name) @(\(point.x),\(point.y)) = \(ratio)") + } + // Lens + badge fills sit over the field: their text pairs at the corner too. + let lens = Palette.lensFill.dark.over(backdrop) + for (name, label) in [("label", Palette.label), + ("labelSecondary", Palette.labelSecondary)] { + let ratio = ContrastAuditTests.ratio(label: label, on: lens, .dark) + #expect(ratio >= textAA, "\(name) on lens⊕corner @(\(point.x),\(point.y)) = \(ratio)") + } + let badge = Palette.badgeFill.dark.over(backdrop) + for (name, label) in [("label", Palette.label), + ("statusWarningText", Palette.statusWarningText)] { + let ratio = ContrastAuditTests.ratio(label: label, on: badge, .dark) + #expect(ratio >= textAA, "\(name) on badge⊕corner @(\(point.x),\(point.y)) = \(ratio)") + } + // R4-PANEL-01 pairs (review MAJOR-3): the inspector panel renders over the field on + // the RIGHT side — tertiary text's designed home (§3.3 placement rule), so ALL three + // label rungs are audited on panel⊕field there. The bottom BLEED stratum is NOT a + // text surface: folding it measured tertiary at 4.18 (break-it catch), so the + // constraint is ENCODED instead of diluted — `InspectorColumn`'s bottom content + // inset IS `GlassDecor.bleedHeight` (same token, cannot drift), meaning text never + // RESTS on the bleed run; transient scroll crossings are accepted (the seam-feather + // class). + if point.x >= 0.5 { + let panel = Palette.panelFill.dark.over(backdrop) + for (name, label) in [("label", Palette.label), + ("labelSecondary", Palette.labelSecondary), + ("labelTertiary", Palette.labelTertiary)] { + let ratio = ContrastAuditTests.ratio(label: label, on: panel, .dark) + #expect(ratio >= textAA, "\(name) on panel⊕corner @(\(point.x),\(point.y)) = \(ratio)") + } + } + } +} diff --git a/Tests/DesignTokenKitTests/GlowFieldTests.swift b/Tests/DesignTokenKitTests/GlowFieldTests.swift new file mode 100644 index 0000000..d6b2c8e --- /dev/null +++ b/Tests/DesignTokenKitTests/GlowFieldTests.swift @@ -0,0 +1,64 @@ +// RES-04 + glow-spec invariants (S10.7 PR 2, design §7 R2/§3.3). + +import DesignTokenKit +import Testing + +@Suite("Glow field — visibility resolver + spec invariants") +struct GlowFieldTests { + /// The glow field is dark-only translucency decoration (PR-2 review MAJOR 4: any + /// mid-luminance hue composited over the near-white light window DARKENS it — a stain); + /// any accessibility opacity request also wins. + @Test("RES-04: glows render only in dark with no RT/IC request — full cube") + func visibilityResolution() { + for appearance in TokenAppearance.allCases { + for reduceTransparency in [false, true] { + for increasedContrast in [false, true] { + let visible = glowFieldIsVisible(appearance: appearance, + reduceTransparency: reduceTransparency, + increasedContrast: increasedContrast) + let expected = appearance == .dark && !reduceTransparency && !increasedContrast + #expect(visible == expected, + "\(appearance)/rt=\(reduceTransparency)/ic=\(increasedContrast)") + } + } + } + } + + /// Light-grammar rule 5 (§3.2): the light alphas stay recorded as the S10.8 starting + /// point (materially subtler than dark) even though the resolver suppresses light + /// rendering entirely this sprint. + @Test("GLOW-01: every glow's light alpha is at most half its dark alpha (grammar rule 5)") + func lightAlphasAreSubtler() { + for glow in GlowFieldSpec.glows { + #expect(glow.color.light.alpha <= glow.color.dark.alpha / 2, + "light glow alpha \(glow.color.light.alpha) vs dark \(glow.color.dark.alpha)") + } + } + + /// The falloff profile is the mock's exact-linear ramp (PR-2 review MAJOR 3): endpoints + /// pinned, mid stop on the line, monotone decreasing — derived from the constants, so a + /// stop retune keeps the profile honest or fails loud. + @Test("GLOW-02: falloffFraction is the exact-linear ramp through the declared stops") + func falloffProfile() { + #expect(GlowFieldSpec.falloffFraction(at: 0) == 1) + #expect(GlowFieldSpec.falloffFraction(at: 1) == 0) + #expect(abs(GlowFieldSpec.falloffFraction(at: GlowFieldSpec.falloffMidStop) + - GlowFieldSpec.falloffMidAlphaFactor) < 1e-12) + // Exact linearity of both segments (slope −1 when factor = 1 − midStop·slope): + let quarter = GlowFieldSpec.falloffMidStop / 2 + let expectedAtQuarter = 1 - (1 - GlowFieldSpec.falloffMidAlphaFactor) / 2 + #expect(abs(GlowFieldSpec.falloffFraction(at: quarter) - expectedAtQuarter) < 1e-12) + var previous = 1.0 + for step in 1 ... 20 { + let value = GlowFieldSpec.falloffFraction(at: Double(step) / 20) + #expect(value <= previous, "falloff must be monotone decreasing") + previous = value + } + } + + /// The seam feather exists while the shell bands are flat (removed in PR 6). + @Test("GLOW-03: seam feather is a real run of points") + func seamFeather() { + #expect(GlowFieldSpec.seamFeather > 0) + } +} diff --git a/Tests/DesignTokenKitTests/PeakHoldTrackerTests.swift b/Tests/DesignTokenKitTests/PeakHoldTrackerTests.swift new file mode 100644 index 0000000..4044658 --- /dev/null +++ b/Tests/DesignTokenKitTests/PeakHoldTrackerTests.swift @@ -0,0 +1,100 @@ +// PH — PeakHoldTracker cases (design §7 R2). Derived expectations only: every number below +// is computed from the tracker's own config, never a pixel/magic literal. The tracker is +// time-FED, so pause semantics (PH-06) are structural, not timed. + +import DesignTokenKit +import Testing + +@Suite("PeakHoldTracker (PH)") +struct PeakHoldTrackerTests { + private let config = PeakHoldConfig() + + private func tracker(_ bands: Int = 3) -> PeakHoldTracker { + PeakHoldTracker(bandCount: bands, config: config) + } + + @Test("PH-01: the cap always rides at or above the live bar, re-latching a rise instantly") + func capNeverBelowBar() { + var sut = tracker(1) + sut.update(bars: [0.4], elapsed: 0.05) + #expect(sut.caps[0] == 0.4) + sut.update(bars: [0.9], elapsed: 0.05) + #expect(sut.caps[0] == 0.9, "a rising bar re-latches immediately") + } + + @Test("PH-02: a latched cap holds its value for exactly holdSeconds of fed time") + func holdWindow() { + var sut = tracker(1) + sut.update(bars: [0.8], elapsed: 0.05) // latch + let justUnderHold = config.holdSeconds - 0.001 + sut.update(bars: [0.1], elapsed: justUnderHold) + #expect(sut.caps[0] == 0.8, "unchanged at hold − ε") + } + + @Test("PH-03: past the hold, the cap decays linearly at decayPerSecond") + func linearDecay() { + var sut = tracker(1) + sut.update(bars: [0.8], elapsed: 0.05) // latch (hold restarts) + let delta = 0.2 + sut.update(bars: [0.0], elapsed: config.holdSeconds + delta) + let expected = 0.8 - config.decayPerSecond * delta + #expect(abs(sut.caps[0] - expected) < 1e-12, + "cap \(sut.caps[0]) vs derived \(expected)") + } + + @Test("PH-04: decay floors at the live bar and never goes negative") + func decayFloors() { + var sut = tracker(2) + sut.update(bars: [0.8, 0.8], elapsed: 0.05) + sut.update(bars: [0.5, 0.0], elapsed: config.holdSeconds + 10) // decay far past both + #expect(sut.caps[0] == 0.5, "floors at the live bar") + #expect(sut.caps[1] == 0.0, "floors at zero") + } + + @Test("PH-05: a new higher bar re-latches AND restarts the hold window") + func relatchRestartsHold() { + var sut = tracker(1) + sut.update(bars: [0.5], elapsed: 0.05) + sut.update(bars: [0.2], elapsed: config.holdSeconds / 2) // half the hold consumed + sut.update(bars: [0.7], elapsed: 0.05) // re-latch + sut.update(bars: [0.1], elapsed: config.holdSeconds - 0.001) // fresh, full hold + #expect(sut.caps[0] == 0.7, "the hold restarted at re-latch") + } + + @Test("PH-06: no feed, no decay — pause is structural") + func pauseIsStructural() { + var sut = tracker(1) + sut.update(bars: [0.8], elapsed: 0.05) + let frozen = sut.caps + // Nothing calls update. However much wall time "passes", the caps are identical. + #expect(sut.caps == frozen) + } + + @Test("PH-07: reset clears to the live bars (or zero)") + func reset() { + var sut = tracker(2) + sut.update(bars: [0.8, 0.6], elapsed: 0.05) + sut.reset(to: [0.3]) + #expect(sut.caps == [0.3, 0.0], "reset takes given bars, zero-fills the rest") + } + + @Test("PH-08: a band-count change resizes and resets the new layout without crashing") + func bandCountChange() { + var sut = tracker(2) + sut.update(bars: [0.8, 0.6], elapsed: 0.05) + sut.update(bars: [0.1, 0.2, 0.3, 0.4], elapsed: 0.05) + #expect(sut.caps == [0.1, 0.2, 0.3, 0.4], "resized layout starts from the live bars") + } + + @Test("PH-09: hostile input clamps — negative elapsed, NaN/∞/out-of-range bars") + func hostileInput() { + var sut = tracker(4) + sut.update(bars: [0.5, 0.5, 0.5, 0.5], elapsed: 0.05) + sut.update(bars: [Double.nan, .infinity, -3, 7], elapsed: -5) + for cap in sut.caps { + #expect(cap.isFinite && cap >= 0 && cap <= 1, "cap \(cap) escaped [0, 1]") + } + // Negative elapsed must not decay (clamped to 0): band 3 latched to 1.0 (clamped 7). + #expect(sut.caps[3] == 1.0) + } +} diff --git a/Tests/DesignTokenKitTests/SampledGlowTests.swift b/Tests/DesignTokenKitTests/SampledGlowTests.swift new file mode 100644 index 0000000..b0ac915 --- /dev/null +++ b/Tests/DesignTokenKitTests/SampledGlowTests.swift @@ -0,0 +1,117 @@ +// D8 — the art-sampled glow pipeline's pure half (S10.7 PR 7): the clamp that makes every +// sampled color audit-admissible, and the dominant-color selection. House style: derived +// expectations (bounds come from the Kit constants, never re-typed magic numbers). + +import DesignTokenKit +import Testing + +@Suite("Sampled glows — clamp + selection (D8)") +struct SampledGlowTests { + /// Chromatic extremes a pathological cover can produce; every one must land in the + /// slot's box. (Achromatic extremes — white frames, black letterboxing — are the + /// FLOORS' cases, D8-CLAMP-03: they reject to brand, they don't clamp.) + private static let hostileSamples: [RGBAColor] = [ + RGBAColor(red: 1.0, green: 0.05, blue: 0.05), // neon red + RGBAColor(red: 0.95, green: 0.95, blue: 0.1), // neon yellow + RGBAColor(red: 0.3, green: 0.6, blue: 1.0), // bright blue + RGBAColor(red: 0.62, green: 0.3, blue: 0.9), // violet + ] + + @Test("D8-CLAMP-01: clamped output is inside the slot box with the slot's token alpha") + func clampedInsideBox() { + for slot in GlowFieldSpec.glows.indices { + for sample in Self.hostileSamples { + guard let clamped = SampledGlow.clampedSampledColor(sample, slot: slot) else { + Issue.record("vivid sample unexpectedly rejected: \(sample) slot \(slot)") + continue + } + let ceiling = SampledGlow.channelMax[slot] + for (channel, name) in [(clamped.red, "red"), (clamped.green, "green"), + (clamped.blue, "blue")] { + #expect(channel <= ceiling + 1e-9, "\(name) \(channel) > \(ceiling) slot \(slot)") + } + #expect(clamped.alpha == GlowFieldSpec.glows[slot].color.dark.alpha, + "alpha must be the slot's token dark alpha, never sampled") + } + } + } + + @Test("D8-CLAMP-02: scale-down preserves hue (channel ratios), never truncates per-channel") + func clampPreservesHue() throws { + let vivid = RGBAColor(red: 1.0, green: 0.4, blue: 0.1) + let clamped = SampledGlow.clampedSampledColor(vivid, slot: 0) + let scaled = try #require(clamped) + // Proportional scale: green/red and blue/red ratios survive. + #expect(abs(scaled.green / scaled.red - vivid.green / vivid.red) < 1e-9) + #expect(abs(scaled.blue / scaled.red - vivid.blue / vivid.red) < 1e-9) + // And the max channel sits exactly at the ceiling (it was above it). + #expect(abs(scaled.red - SampledGlow.channelMax[0]) < 1e-9) + } + + @Test("D8-CLAMP-03: aesthetic floors reject near-black and near-gray toward brand fallback") + func clampFloors() { + // Darker than the floor (max channel below minMaxChannel). + let dark = RGBAColor(red: SampledGlow.minMaxChannel - 0.02, + green: SampledGlow.minMaxChannel - 0.05, blue: 0.05) + #expect(SampledGlow.clampedSampledColor(dark, slot: 0) == nil) + // Grayer than the floor (spread below minChannelSpread). + let gray = RGBAColor(red: 0.6, green: 0.6 - SampledGlow.minChannelSpread + 0.01, blue: 0.6) + #expect(SampledGlow.clampedSampledColor(gray, slot: 0) == nil) + // POST-SCALE floor (review MINOR-4): a barely-chromatic bright sample whose spread + // collapses under the ceiling scale-down rejects — the floor judges what would RENDER. + let barelyChromatic = RGBAColor(red: 1.0, green: 0.95, blue: 0.95) + #expect(SampledGlow.clampedSampledColor(barelyChromatic, slot: 0) == nil) + // An out-of-range slot is a programming error surfaced as fallback, never a crash. + #expect(SampledGlow.clampedSampledColor(.gray(0.5), slot: 99) == nil) + } + + @Test("D8-CLAMP-04: clamping is idempotent per slot") + func clampIdempotent() { + for slot in GlowFieldSpec.glows.indices { + for sample in Self.hostileSamples { + guard let once = SampledGlow.clampedSampledColor(sample, slot: slot) else { continue } + let twice = SampledGlow.clampedSampledColor(once, slot: slot) + #expect(twice == once, "re-clamp must be a no-op (slot \(slot))") + } + } + } + + @Test("D8-SEL-01: selection is deterministic, strongest-chroma-first, achromatic pixels don't vote") + func selectionRanksChromaticColors() { + // 60 vivid red + 30 vivid blue + 40 black + 40 white pixels: red then blue, nothing else. + let red = RGBAColor(red: 0.9, green: 0.1, blue: 0.1) + let blue = RGBAColor(red: 0.1, green: 0.2, blue: 0.9) + var samples = Array(repeating: red, count: 60) + Array(repeating: blue, count: 30) + samples += Array(repeating: RGBAColor.gray(0.02), count: 40) // below the value floor + samples += Array(repeating: RGBAColor.gray(0.98), count: 40) // below the saturation floor + let picked = SampledGlow.dominantColors(samples: samples) + #expect(picked.count == 2) + // Strongest first: the red bucket outweighs blue. Averages stay in-family. + #expect(picked[0].red > picked[0].blue, "first pick should be the red family") + #expect(picked[1].blue > picked[1].red, "second pick should be the blue family") + // Deterministic: same input, same output. + #expect(SampledGlow.dominantColors(samples: samples) == picked) + } + + @Test("D8-SEL-02: an achromatic cover yields no picks (every slot falls back to brand)") + func selectionAchromaticCover() { + let samples = (0 ..< 100).map { RGBAColor.gray(Double($0) / 100.0) } + #expect(SampledGlow.dominantColors(samples: samples).isEmpty) + } + + @Test("D8-TOK: clamp constants are sane and parallel to the glow slots") + func clampConstants() { + #expect(SampledGlow.channelMax.count == GlowFieldSpec.glows.count) + for ceiling in SampledGlow.channelMax { + #expect(ceiling > 0 && ceiling <= 1) + } + #expect(SampledGlow.minMaxChannel > 0 && SampledGlow.minMaxChannel < 1) + #expect(SampledGlow.minChannelSpread > 0 && SampledGlow.minChannelSpread < 1) + // The audit corner carries the slots' token dark alphas — the audited quantity. + let corner = SampledGlow.auditCornerPalette + #expect(corner.count == GlowFieldSpec.glows.count) + for (slot, color) in corner.enumerated() { + #expect(color?.alpha == GlowFieldSpec.glows[slot].color.dark.alpha) + } + } +} diff --git a/Tests/DesignTokenKitTests/SlotFitTests.swift b/Tests/DesignTokenKitTests/SlotFitTests.swift new file mode 100644 index 0000000..fa24145 --- /dev/null +++ b/Tests/DesignTokenKitTests/SlotFitTests.swift @@ -0,0 +1,55 @@ +// §7.1 SlotFitTests — the S9 LUFS-truncation class, asserted headlessly: the widest +// LEGITIMATE string for each fixed slot must fit its slot token. Honestly a gross-misfit +// net: NSFont metrics ≈ (not ==) SwiftUI's resolved font, so the margin absorbs the seam — +// this catches "someone shrank the slot token or widened the format string," not 1-pt clips. +// AppKit is allowed HERE (the TEST target measures); the Kit itself stays UI-import-free. + +import AppKit +import DesignTokenKit +import Testing + +@Suite("Fixed-slot fit (S9 truncation class)") +struct SlotFitTests { + /// Measure a string in the app's monospaced small-readout style (`DesignSystem.Font + /// .monoSmall` = subheadline, monospaced design) at REGULAR weight — the footer time + /// labels' actual configuration (NowPlayingBar's slot-constrained labels use plain + /// `monoSmall`; the semibold variant is the scrubber tooltip, which is not + /// slot-constrained — review MINOR-1). + private func monoSmallWidth(_ string: String) -> Double { + let size = NSFont.preferredFont(forTextStyle: .subheadline).pointSize + let font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + return NSAttributedString(string: string, attributes: [.font: font]).size().width + } + + /// Derived from the slot token — no magic pixel expectations. + @Test("SLOT-01: the widest mm:ss fits the footer time-label slot with margin") + func footerTimeLabelFits() { + let measured = monoSmallWidth("88:88") + // 2pt margin: absorbs the NSFont↔SwiftUI metric seam without masking a slot shrink. + #expect(measured <= SlotWidths.footerTimeLabel - 2, + "'88:88' measures \(measured)pt against the \(SlotWidths.footerTimeLabel)pt slot") + } + + /// D5 chrome readout: the widest legitimate rate `SignalPathInfo.rateString` emits is a + /// high-res fractional string — "176.4 kHz" (176 400 Hz, 9 chars). Literal here (the + /// formatter lives in the app target, out of the Kit test's reach); if the format string + /// ever changes, update both. + @Test("SLOT-02: the widest sample-rate string fits the chrome device-pill readout slot") + func chromeSampleRateFits() { + let measured = monoSmallWidth("176.4 kHz") + #expect(measured <= SlotWidths.chromeSampleRate - 2, + "'176.4 kHz' measures \(measured)pt against the \(SlotWidths.chromeSampleRate)pt slot") + } + + /// The footer signal slot renders `Circle(6pt) + HStack(spacing:5) + Text` (NowPlayingBar + /// R4); the widest legitimate text is the Enhanced path at a fractional hi-res rate. + /// The 120pt slot truncated "Enhanced · 48 kHz" to "48 k…" the moment Enhanced published + /// a real rate (founder screenshot, PR-6 round) — this asserts the full composition fits. + @Test("SLOT-03: the widest signal-path readout (+ status dot) fits the footer signal slot") + func footerSignalSlotFits() { + let dotAndGap = 6.0 + 5.0 // NowPlayingBar R4: 6pt status dot + HStack spacing 5 + let measured = monoSmallWidth("Enhanced · 176.4 kHz") + dotAndGap + #expect(measured <= SlotWidths.footerSignalSlot - 2, + "dot + 'Enhanced · 176.4 kHz' measures \(measured)pt against the \(SlotWidths.footerSignalSlot)pt slot") + } +} diff --git a/Tests/DesignTokenKitTests/SurfaceResolverTests.swift b/Tests/DesignTokenKitTests/SurfaceResolverTests.swift new file mode 100644 index 0000000..104c4f9 --- /dev/null +++ b/Tests/DesignTokenKitTests/SurfaceResolverTests.swift @@ -0,0 +1,89 @@ +// RES — resolver contract tests (design §7 R2). PR 1a scope: the `.overlay` role. The fill +// roles' RT-swap cases (RES-01/02 for translucent fills) land with their roles in PRs 2–6. + +import DesignTokenKit +import Testing + +@Suite("SurfaceResolver — overlay (RES)") +struct SurfaceResolverOverlayTests { + /// One point of the appearance × RT × IC flag cube. + private struct FlagCase { + let appearance: TokenAppearance + let reduceTransparency: Bool + let increasedContrast: Bool + } + + /// Every (appearance × RT × IC) combination — the full flag cube, derived not enumerated + /// by hand, so a new flag dimension can't silently escape coverage. + private static let cube: [FlagCase] = TokenAppearance.allCases.flatMap { appearance in + [false, true].flatMap { reduceTransparency in + [false, true].map { increasedContrast in + FlagCase(appearance: appearance, + reduceTransparency: reduceTransparency, + increasedContrast: increasedContrast) + } + } + } + + /// Native adaptation ownership: the resolver never substitutes a fill for an overlay. + @Test("RES-OV-01: overlay resolves to its system material for the entire flag cube") + func overlayIsAlwaysSystemMaterial() { + for substrate in OverlaySubstrate.allCases { + for point in Self.cube { + let resolved = resolveSurface(role: .overlay(substrate), + appearance: point.appearance, + reduceTransparency: point.reduceTransparency, + increasedContrast: point.increasedContrast) + #expect(resolved == .systemMaterial(substrate), + "overlay(\(substrate)) substituted under \(point)") + } + } + } + + /// The fill roles and their token pairs — one table drives RES-01/02 (new fill roles + /// join here, and only here). + private static let fillRoles: [(role: SurfaceRole, pair: AppearancePair)] = [ + (.lens, Palette.lensFill), + (.badge, Palette.badgeFill), + (.panel, Palette.panelFill), + ] + + @Test("RES-01: fill roles resolve to their translucent token when transparency is allowed") + func fillTranslucent() { + for (role, pair) in Self.fillRoles { + for appearance in TokenAppearance.allCases { + let resolved = resolveSurface(role: role, appearance: appearance, + reduceTransparency: false, increasedContrast: false) + #expect(resolved == .fill(pair.value(for: appearance)), "\(role)") + } + } + } + + @Test("RES-02: fill roles go OPAQUE (fill⊕window) under RT — and under IC even without RT") + func fillOpaqueFallback() { + for (role, pair) in Self.fillRoles { + for point in Self.cube where point.reduceTransparency || point.increasedContrast { + let resolved = resolveSurface(role: role, + appearance: point.appearance, + reduceTransparency: point.reduceTransparency, + increasedContrast: point.increasedContrast) + let fill = pair.value(for: point.appearance, + increasedContrast: point.increasedContrast) + let window = Palette.window.value(for: point.appearance, + increasedContrast: point.increasedContrast) + #expect(resolved == .fill(fill.over(window)), "\(role) stayed translucent under \(point)") + if case let .fill(color) = resolved { + #expect(color.alpha == 1.0, "\(role) RT/IC fallback must be fully opaque") + } + } + } + } + + @Test("PG-01..04: the pulse animates ONLY while playing with Reduce Motion off — full table") + func pulseGate() { + #expect(pulseIsActive(isPlaying: true, reduceMotion: false)) + #expect(!pulseIsActive(isPlaying: true, reduceMotion: true)) + #expect(!pulseIsActive(isPlaying: false, reduceMotion: false)) + #expect(!pulseIsActive(isPlaying: false, reduceMotion: true)) + } +} diff --git a/Tests/DesignTokenKitTests/TokenInvariantTests.swift b/Tests/DesignTokenKitTests/TokenInvariantTests.swift new file mode 100644 index 0000000..b65b6d9 --- /dev/null +++ b/Tests/DesignTokenKitTests/TokenInvariantTests.swift @@ -0,0 +1,80 @@ +// TOK — palette-wide invariants (design §7 R2), driven by the `Palette.all` registry so a +// token missing from the registry is itself a reviewable gap, not a silent one. (TOK-01, the +// radii-concentricity chain, joins when `Glass.Radius` lands with the fill roles.) + +import DesignTokenKit +import Testing + +@Suite("Palette invariants (TOK)") +struct TokenInvariantTests { + /// Channels and alpha in [0, 1]; a fully-transparent token is a mistake, not a color. + @Test("TOK-02: every registered token has valid channel + alpha values") + func componentRanges() { + for (name, pair) in Palette.all { + for appearance in TokenAppearance.allCases { + for increasedContrast in [false, true] { + let value = pair.value(for: appearance, increasedContrast: increasedContrast) + for (channel, label) in [(value.red, "red"), (value.green, "green"), + (value.blue, "blue"), (value.alpha, "alpha")] { + #expect((0.0 ... 1.0).contains(channel), + "\(name).\(appearance) \(label) out of range: \(channel)") + } + #expect(value.alpha > 0, "\(name).\(appearance) is fully transparent") + } + } + } + } + + /// So Increase Contrast can never resolve to an unset/garbage value. + @Test("TOK-03: high-contrast variants default to base values until a token opts in") + func highContrastDefaults() { + // Deliberate opt-ins (each names its design contract; anything NOT listed must + // still default — a new distinct HC variant fails here until added deliberately): + // glassHairline — §3.2 "stronger hairlines under Increase Contrast" (PR 3). + let optedIn: Set = ["glassHairline"] + for (name, pair) in Palette.all { + for appearance in TokenAppearance.allCases { + let base = pair.value(for: appearance, increasedContrast: false) + let highContrast = pair.value(for: appearance, increasedContrast: true) + if optedIn.contains(name) { + #expect(highContrast.alpha > base.alpha, + "\(name).\(appearance): an opted-in hairline must be STRONGER under IC") + } else { + #expect(highContrast == base, + "\(name).\(appearance): unexpected distinct HC variant — update this test if intentional") + } + } + } + } + + /// rowNowPlaying/rowSelected are the accent at documented alphas, never drifting values. + @Test("TOK-04: derived row tints stay derived from the accent") + func rowTintsDeriveFromAccent() { + let accent = Palette.accent.light + #expect(Palette.rowNowPlaying.light == accent.opacity(0.25)) + #expect(Palette.rowSelected.light == accent.opacity(0.12)) + #expect(Palette.rowNowPlaying.light == Palette.rowNowPlaying.dark, + "rowNowPlaying is appearance-independent (accent-derived)") + #expect(Palette.rowSelected.light == Palette.rowSelected.dark, + "rowSelected is appearance-independent (accent-derived)") + } + + /// The 8a concentric chain (§3.2): outer radii are never smaller than inner ones. + /// Grows as roles land (rows/badges join with their tokens). + @Test("TOK-01: the glass radii chain stays monotone (panel ≥ lens)") + func radiiChain() { + #expect(GlassDecor.panelRadius >= GlassDecor.lensRadius, + "panel \(GlassDecor.panelRadius) must be ≥ lens \(GlassDecor.lensRadius)") + } + + /// The audit engine's ground truth: opaque-over-anything is itself; results go opaque. + @Test("TOK-05: compositing sanity — opaque identity + opaque results") + func compositingGroundTruth() { + let backdrop = Palette.window.dark + let opaque = Palette.accent.light + #expect(opaque.over(backdrop) == opaque) + let translucent = Palette.card.dark + let composite = translucent.over(backdrop) + #expect(composite.alpha == 1.0, "translucent over opaque must yield opaque") + } +} diff --git a/Tests/LibraryBrowseKitTests/FacetTextFilterTests.swift b/Tests/LibraryBrowseKitTests/FacetTextFilterTests.swift index 8de2f3c..49785f3 100644 --- a/Tests/LibraryBrowseKitTests/FacetTextFilterTests.swift +++ b/Tests/LibraryBrowseKitTests/FacetTextFilterTests.swift @@ -30,4 +30,11 @@ struct FacetTextFilterTests { func trimmed() { #expect(FacetTextFilter.matches("Jazz", query: " jazz ")) } + + @Test("diacritic-insensitive (S10.7 §5 queue-filter contract)") + func diacriticInsensitive() { + #expect(FacetTextFilter.matches("Beyoncé", query: "beyonce")) + #expect(FacetTextFilter.matches("Sigur Rós", query: "ros")) + #expect(FacetTextFilter.matches("Motörhead", query: "motorhead")) + } } diff --git a/docs/design/now-playing-7a/DEVIATIONS.md b/docs/design/now-playing-7a/DEVIATIONS.md new file mode 100644 index 0000000..54a272f --- /dev/null +++ b/docs/design/now-playing-7a/DEVIATIONS.md @@ -0,0 +1,60 @@ +# Design-deviation report — Now Playing vs release design 8a (7a + polish) + +**For: Claude (fable 5) session in VS Code, repo `ramith/sound-engineering`.** +Visual truth: `Player Layout Variants.dc.html` → card **8a** (turn 8). Material recipes: `README.md` §"Liquid Glass adoption layer". Screenshot audited: 2026-07-19 implementation build. + +General verdict: layout skeleton is right (hero title + analyzer, queue left, inspector right, footer transport). The deviations are mostly **material language (glass), control styling, and queue-row treatment**. Work through the numbered items; each lists the likely code home (verify — some views may have moved since commit `82bc287`). + +--- + +## 1. Toolbar / ChromeBar — biggest visible gap +File: `Sources/AdaptiveSound/UI/Shell/ChromeBar.swift` +- **Tabs are a native segmented picker with divider lines and no active fill.** Design: capsule track (`rgba(0,0,0,.35)` fill, radius = height/2, inset shadow) with the ACTIVE tab as a teal gradient capsule (`#4FD2C0→#1FA893` vertical, dark text `#0c1413`, inner top highlight, soft teal glow) and hover states on inactive tabs. Replace `.pickerStyle(.segmented)` in `TabSelectorView` with a custom capsule control (keep the accessibility labels and reduce-motion animation gating). +- **Device pill truncates** ("MacBook Pr…"). The fixed 200pt width is too narrow once the 48 kHz readout moved outside it. Design: name + `48 kHz` mono readout INSIDE one pill, width ~240pt (keep fixed-width invariant), capsule radius (17) not rect-8, glass fill (white 9% + specular rim) instead of `Color.asCard`. +- **Logo glyph**: uses `music.note`; design/brand mark is the 5-bar waveform. Use the existing brand asset or `waveform` SF Symbol, squircle radius 9, teal gradient (already correct). +- Whole bar: apply glass material (blur + saturation, specular bottom hairline) per README §2 — currently flat. + +## 2. Spectrum analyzer — bars are LED-segmented, design is solid +Files: `Sources/AdaptiveSound/UI/Spectrum/SpectrumAnalyzerView.swift` (+ whatever view wraps it in the hero lens now) +- **Bars render as dashed/segmented LED columns with dotted floating peak marks.** Design 8a: **solid smooth bars** (vertical gradient per bar, top radius 1.5) with a single **2px peak-hold cap** hovering 4px above each bar at 50% opacity of the bar color. Remove the segmentation; keep `SpectrumColorPalette` (correct). +- **Lens styling missing**: the panel is a flat dark rect with a plain border. Apply the glass-lens recipe: radius 20, fill `rgba(16,18,21,.42)` + blur/saturation, specular top rim, hairline, bottom light bleed, drop shadow; 4 horizontal gridlines (white 4–6%). +- Scale labels exist (good) — keep `0 dB` top-right and 20 Hz–20 kHz row. + +## 3. Queue — row treatment and header +Files: `Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift`, `PlaylistItemRow.swift` +- **Drag handles (≡) are permanently visible on every row.** Design has none — rows lead with the index number (active row: animated 3-bar equalizer). Show the handle only on hover, or rely on drag-anywhere reordering. +- **Active row**: currently a heavy full-bleed teal band with a play triangle. Design: subtle `teal 16%` fill + 1px teal ring, radius 12, title in `#8AF0E0` semibold, index replaced by the 3-bar mini equalizer (animates only while playing; respect Reduce Motion). Remove the play-triangle glyph. +- **Filter field**: full-width giant bar. Design: compact right-aligned capsule pill (~30pt high, magnifier + "Filter queue") in the queue header row. +- **Header**: "QUEUE / 6 tracks" mono block floats far above the list, and queue action buttons (trash/shuffle/repeat/play) float separately mid-right. Consolidate into ONE header row directly above the list: `Queue` (14pt bold) · `6 tracks · ` (11pt mono, 40% white) · spacer · actions + filter pill. Keep the Up Next / Recent segmented control but style it as a small capsule pair, left-aligned in the same header block rather than centered in open space. +- Row format badge: keep, but capsule radius (9) and fixed 18pt height per 8a. +- Missing: full file-path tooltip on rows (`.help(track.url.path)`). + +## 4. Inspector column (Master Gain / Intensity / Loudness / Headphones) +Files: `NowPlayingInfoView.swift` (ReimagineSectionView, HeadphonesSectionView), `MasterGainSliderView.swift`, `LoudnessMetersView.swift`, container `NowPlayingTabView.swift`/`RightPanelView.swift` +- **Panel is a flat full-height dark card touching the content edges with a large empty bottom region.** Design: floating glass panel — radius 22, glass fill + blur, specular rim, hairline, drop shadow, inset 16pt from window right/bottom, top aligned with the queue header, height hugging content (no stretch; let the glow background show below). +- **Sliders are native NSSlider style.** Design: 5pt carved track (`inset` shadow), teal gradient fill with subtle glow, 14pt white round knob. Build one custom slim slider style, reuse for gain + intensity (keep bindings/accessibility). +- **Master gain value** shows `4.0 dB` → must be signed `+4.0 dB`. +- **Loudness**: Integrated/Short-term are text-only and Peak is a bare bar with no value. Design: all three rows = label (62pt) + 6pt gradient meter bar + right-aligned mono value; True peak tips into amber `#FFB347` and its value turns amber above −1 dBTP. Label "True peak", not "Peak". +- **Headphones section**: the long parenthetical paragraph is always visible. Design: single line "Connect headphones to enable." — move the long explanation into a `.help()` tooltip or info popover. Whole block at 55% opacity when disabled (currently 50% — fine), grey knob. +- **"Bypass / Full Blend" caption row**: keep (useful), but style 9.5pt mono 32% white, directly under the intensity slider. +- Section dividers: use gradient-fade hairlines (transparent → white 12% → transparent), not solid. + +## 5. Ambient background glow — wrong hue +Home: background of the Now Playing tab (wherever the glow ZStack was added). +- Current glow reads **amber/olive top-left**. Design: **teal** `rgba(41,182,164,.28)` top-left, **lime** `rgba(200,240,106,.12)` bottom-right, **blue** `rgba(79,178,214,.10)` mid-right — all heavily blurred, dark appearance. Check that the teal glow isn't compositing over a warm base or using the wrong color token. + +## 6. Hero badges +File: hero view (evolved from `NowPlayingWidget`/`NowPlayingInfoView`) +- Currently three badges: `ENHANCED` / `48 kHz` / `20 %`. Design: two — `ENHANCED · 20%` (one badge, pulsing 5px dot, teal glass) and `MP3 · 48 kHz` (format + rate combined, grey glass). Fold intensity into the ENHANCED badge and add the missing **format** (MP3/FLAC) segment. Fixed 22pt height, capsule radius, artist separated from badges by a 3px dot. + +## 7. Footer transport (NowPlayingBar) +File: `Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift` +- Keep structure (correct per INTEGRATION.md). Apply glass restyle only: specular top hairline on the bar, scrubber → 5pt carved track + teal gradient fill + 14pt knob, play button gains inner top highlight + bottom shade. Right-side `Enhanced · 48 kHz` readout is correct. + +## 8. Cross-cutting +- All new colors via `DesignSystem` tokens with light-mode variants; respect `accessibilityReduceTransparency` (opaque fallback) and Reduce Motion (no pulse/equalizer animation). +- Alignment grid: floating panels inset 16pt from window edges; 26–28pt left text gutter (repo uses 28 — keep repo value consistently); inspector top aligns with queue header row. +- Run both appearances + `scripts/strict-gate.sh` before each commit; work as small PRs in the order: analyzer → toolbar → queue → inspector → glow → badges → footer. + +## Suggested opening prompt for the session +> Fix the Now Playing screen's deviations from the release design per docs/design/now-playing-7a/DEVIATIONS.md, one numbered section per PR, starting with §2 (analyzer). Visual truth is the 8a card in Player Layout Variants.dc.html. Don't restructure AppShell or move the transport; all colors through DesignSystem tokens; respect Reduce Transparency/Motion; strict-gate must pass. diff --git a/docs/design/now-playing-7a/INTEGRATION.md b/docs/design/now-playing-7a/INTEGRATION.md new file mode 100644 index 0000000..cad7110 --- /dev/null +++ b/docs/design/now-playing-7a/INTEGRATION.md @@ -0,0 +1,46 @@ +# 7a Liquid Glass — Integration guide for `ramith/sound-engineering` + +**Read this BEFORE README.md's generic process.** This guide maps the 7a/8a release design onto the actual codebase (audited @ main, 2026-07-16). The repo has a governed architecture — do NOT drop `NowPlayingView.swift` in as-is; it's a visual reference only. + +## What the codebase already has (don't rebuild) +- **Teal→Lime spectrum**: `UI/Spectrum/SpectrumColorPalette.swift` already implements the exact palette + 0.82 vertical darken; `SpectrumAnalyzerView` already dims to 0.4 when paused, respects Reduce Motion, and runs off real FFT (`AudioViewModel.spectrumBars`, ~20 Hz). ✔ matches design. +- **Shell**: `AppShell` = fixed 60pt `ChromeBar` + bounded content + fixed 64pt footer `NowPlayingBar` (global transport). Native titlebar carries traffic lights. +- **Chrome**: `ChromeBar` = logo squircle + fixed-200pt device pill (`Menu`) + segmented `TabSelectorView`. +- **Now Playing tab**: `NowPlayingTabView` = 50/50 split; `LeftPanelView` (spectrum → master gain → track info, scrollable) + `RightPanelView` → `PlaylistView` (queue). *[Superseded by S10.7 PR 5: the 8a restructure replaced the split with hero-row + queue-flex + 260pt inspector; `LeftPanelView`/`RightPanelView` deleted.]* +- **Tokens**: `DesignSystem.swift` — dynamic light/dark colors (WCAG-audited), Dynamic Type fonts, spacing/radius scales, `ShellMetrics`, `Footer` metrics. New code must use these. +- Loudness meters (`UI/Loudness/LoudnessMetersView.swift`), format badges, queue rows with now-playing/selected tints — all exist. + +## Design ↔ code conflicts to resolve (decide before coding) +1. **Transport location.** 7a puts prev/play/next + scrubber in the Now Playing hero; the app deliberately moved transport to the global footer (`NowPlayingBar`, L3 design doc `docs/sprints/l3-footer-transport-design.md`). **Recommendation: keep the footer transport** (it's global across tabs) and adopt 7a's hero as title + badges + analyzer only. Optionally restyle the footer with the glass recipe. +2. **Floating toolbar capsule.** `ChromeBar` is a full-width band under the native titlebar. Making it a detached floating capsule conflicts with the L2 window-drag setup and the "fixed top-left" invariant. **Recommendation: apply the glass material to the existing band** (background + specular bottom hairline) and to the device pill/tab control, not the floating geometry. +3. **Light mode.** 7a's recipes are dark-tuned; the app is dual-appearance via `DesignSystem.Color.dynamic(light:dark:)`. Every new glass fill needs a light variant (e.g. white-based glass `rgba(255,255,255,.55)` + dark hairlines). +4. **The design's 5-tab set** (adds "Monitoring") matches `TabSelection` — ✔ no change. + +## Incremental implementation plan (small, safe PRs) +**PR 1 — Glass tokens.** Extend `DesignSystem` with a `Glass` enum: fills (`glassPanel`, `glassControl`), rim/hairline/bleed shadow values, radii (panel 22, lens 20), and a `.glassPanel()` ViewModifier built on `.ultraThinMaterial` + overlays. Respect `accessibilityReduceTransparency` (fall back to `Color.panel`). No visual change yet. +**PR 2 — Ambient glow.** Add the radial glow ZStack behind `NowPlayingTabView` (teal top-left, lime bottom-right, blue mid-right; dark appearance only, subtler in light). +**PR 3 — Analyzer lens.** Wrap `SpectrumAnalyzerView` in the glass lens: `.glassPanel(radius: 20)` + dB gridlines + 0 dB label + 20 Hz–20 kHz scale + peak-hold caps (new small overlay in the same file/folder; heights still from `spectrumBars`). +**PR 4 — Hero.** Restyle `NowPlayingInfoView` per 8a: 28pt/800 title (map to `DesignSystem.Font.displayTitle` weight override), artist + ENHANCED/format badges (reuse `FormatBadgeView`, add capsule glass variant), pulsing dot gated on Reduce Motion. +**PR 5 — Inspector.** Restyle `RightPanelView`'s siblings: master gain + intensity sliders (5pt carved tracks, 14pt knobs, glow fills), `LoudnessMetersView` bars, disabled crossfeed block at 55% opacity. Keep it in `LeftPanelView`'s scroll (current home) OR move to a 260pt trailing glass column per 8a — flag: that changes `NowPlayingTabView`'s 50/50 `containerRelativeFrame` split to queue-flex + fixed-260. +**PR 6 — Chrome + footer glass.** Apply glass materials to `ChromeBar` band, device pill, tab control, and `NowPlayingBar`. Keep all `ShellMetrics`/`Footer` metrics unchanged. + +Each PR: `make run` visual check in BOTH appearances + Reduce Transparency/Motion, and the repo's strict CI (`scripts/strict-gate.sh`, swiftlint) must pass. The `.claude/agents/swiftui-pro.md` + `.claude/skills/macos-design/` agents in the repo are the right reviewers to invoke. + +## Prompt to give Claude Code (VS Code) +> Implement the "7a Liquid Glass" release design per docs/design/now-playing-7a/INTEGRATION.md, following its 6-PR plan. Start with PR 1 (DesignSystem.Glass tokens). Visual reference: now-playing-base.html + the 8a card in Player Layout Variants.dc.html. Do not move the transport out of NowPlayingBar; do not change AppShell band structure. All colors via DesignSystem tokens with light+dark variants; respect Reduce Transparency and Reduce Motion. + +## File map (design element → code home) +| Design (8a) | Code | +|---|---| +| Ambient glows | `NowPlayingTabView` background ZStack (new) | +| Toolbar glass | `UI/Shell/ChromeBar.swift` (+`DesignSystem.Glass`) | +| Device pill + 44.1 kHz readout | `ChromeBar.DevicePillView` (rate from `AudioEngineBridge+Devices`) | +| Hero title/badges | `UI/NowPlaying/NowPlayingInfoView.swift` | +| Analyzer lens + grid + peaks | `UI/Spectrum/SpectrumAnalyzerView.swift` (+ small overlays) | +| Spectrum palette | ✔ already `SpectrumColorPalette.swift` | +| Master gain slider | `UI/NowPlaying/MasterGainSliderView.swift` | +| Intensity slider | left panel (bridge: `AudioEngineBridge+IntensityControl`) | +| Loudness meters | `UI/Loudness/LoudnessMetersView.swift` | +| Crossfeed toggle | bridge: `AudioEngineBridge+CrossfeedControl` | +| Queue rows/tooltips | `UI/Playlist/PlaylistView.swift` + row views (tints: `rowNowPlaying`/`rowSelected`) | +| Transport | stays in `UI/Shell/NowPlayingBar.swift` (glass restyle only) | diff --git a/docs/design/now-playing-7a/NowPlayingView.swift b/docs/design/now-playing-7a/NowPlayingView.swift new file mode 100644 index 0000000..da8e1ce --- /dev/null +++ b/docs/design/now-playing-7a/NowPlayingView.swift @@ -0,0 +1,627 @@ +// +// NowPlayingView.swift +// AdaptiveSound — "Now Playing" screen, design variant 5a ("Glass Inspector") +// +// Reference SwiftUI implementation of the handoff design. +// Wire the sample state to your real audio engine / DSP core. +// Requires macOS 13+. +// + +import SwiftUI + +// MARK: - Design tokens + +enum DS { + // Teal accent set + static let teal = Color(hex: 0x29B6A4) + static let tealLight = Color(hex: 0x3FD0BA) + static let tealLighter = Color(hex: 0x4FD2C0) + static let tealText = Color(hex: 0x6FE0D0) + static let tealBright = Color(hex: 0x8AF0E0) + static let tealDeep = Color(hex: 0x1FA893) + static let tealDarkest = Color(hex: 0x14897A) + static let onTeal = Color(hex: 0x0C1413) + static let amber = Color(hex: 0xFFB347) + + static let windowBG = Color(hex: 0x17181B) + + /// Spectrum palette: teal → lime, mapped to horizontal position (low → high freq) + static let spectrumStops: [(t: Double, color: NSColor)] = [ + (0.0, NSColor(hex: 0x1F9D8B)), (0.2, NSColor(hex: 0x36C1AB)), + (0.4, NSColor(hex: 0x4FD2C0)), (0.6, NSColor(hex: 0x7FE3A8)), + (0.8, NSColor(hex: 0xA8EC84)), (1.0, NSColor(hex: 0xC8F06A)), + ] + + static func spectrumColor(at t: Double) -> Color { + let stops = spectrumStops + var i = 0 + while i < stops.count - 2 && t > stops[i + 1].t { + i += 1 + } + let (t0, c0) = stops[i], (t1, c1) = stops[i + 1] + let k = (t - t0) / (t1 - t0) + return Color(nsColor: c0.lerp(to: c1, k: k)) + } +} + +// MARK: - Model + +struct Track: Identifiable { + let id = UUID() + let title: String + let duration: String + let format: String + let absolutePath: String +} + +final class PlayerModel: ObservableObject { + @Published var tracks: [Track] = Track.samples + @Published var currentIndex = 1 + @Published var isPlaying = true + @Published var elapsed: Double = 114 // seconds + @Published var duration: Double = 278 + @Published var masterGainDB: Double = 4.0 // −12…+12 + @Published var intensity: Double = 0.20 // 0…1 + @Published var integratedLUFS: Double = -15.1 + @Published var shortTermLUFS: Double = -14.2 + @Published var truePeakDBTP: Double = -0.8 + @Published var crossfeedEnabled = false + @Published var headphonesConnected = false + /// Log-bucketed FFT magnitudes 0…1, low → high frequency. Feed from the audio engine. + @Published var spectrum: [Double] = (0 ..< 72).map { _ in .random(in: 0.15 ... 0.95) } + + var currentTrack: Track { + tracks[currentIndex] + } + + func togglePlay() { + isPlaying.toggle() + } + + func previous() { + currentIndex = (currentIndex - 1 + tracks.count) % tracks.count; isPlaying = true + } + + func next() { + currentIndex = (currentIndex + 1) % tracks.count; isPlaying = true + } + + func play(_ track: Track) { + if let i = tracks.firstIndex(where: { $0.id == track.id }) { currentIndex = i; isPlaying = true } + } +} + +// MARK: - Root view + +struct NowPlayingView: View { + @StateObject private var model = PlayerModel() + + var body: some View { + VStack(spacing: 0) { + ToolbarView() + HeroBand(model: model) + HStack(spacing: 0) { + QueueColumn(model: model) + InspectorPanel(model: model) + .frame(width: 260) + .padding(.trailing, 14) + .padding(.vertical, 12) + } + .frame(maxHeight: .infinity) + } + .background(DS.windowBG) + .frame(minWidth: 1120, minHeight: 720) + } +} + +// MARK: - Toolbar + +struct ToolbarView: View { + @State private var selectedTab = "Now Playing" + private let tabs = ["Now Playing", "Library", "EQ", "Monitoring", "Settings"] + + var body: some View { + HStack(spacing: 14) { + // App mark + RoundedRectangle(cornerRadius: 8) + .fill(LinearGradient(colors: [DS.tealLight, DS.tealDarkest], + startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 26, height: 26) + .overlay(Image(systemName: "waveform").font(.system(size: 12, weight: .bold)).foregroundStyle(.white)) + + // Output device capsule (single source of device selection) + Menu { + Button("MacBook Pro Speakers") {} + Button("External Headphones") {} + } label: { + HStack(spacing: 8) { + Image(systemName: "speaker.wave.2").font(.system(size: 11)).foregroundStyle(DS.tealLighter) + Text("MacBook Pro Speakers").font(.system(size: 12.5, weight: .semibold)) + Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold)).opacity(0.5) + } + .padding(.horizontal, 13).frame(height: 32) + .background(.white.opacity(0.08), in: Capsule()) + } + .buttonStyle(.plain) + + // Tab capsule segmented control + HStack(spacing: 2) { + ForEach(tabs, id: \.self) { tab in + Button { selectedTab = tab } label: { + Text(tab) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(selectedTab == tab ? DS.onTeal : .white.opacity(0.65)) + .padding(.horizontal, 13).frame(height: 26) + .background(selectedTab == tab ? DS.teal.opacity(0.92) : .clear, in: Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(3) + .background(.black.opacity(0.35), in: Capsule()) + + Spacer() + } + .padding(.horizontal, 16).frame(height: 52) + .background(.ultraThinMaterial) + .overlay(alignment: .bottom) { Divider().opacity(0.4) } + } +} + +// MARK: - Hero band + +struct HeroBand: View { + @ObservedObject var model: PlayerModel + + var body: some View { + HStack(alignment: .top, spacing: 20) { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 7) { + Text(model.currentTrack.title) + .font(.system(size: 28, weight: .heavy)).kerning(-0.4) + .foregroundStyle(.white).lineLimit(1) + HStack(spacing: 8) { + Text("Nirvana").font(.system(size: 13.5, weight: .medium)).foregroundStyle(.white.opacity(0.7)) + BadgeView(text: "ENHANCED \(Int(model.intensity * 100))%", tint: .teal) + BadgeView(text: "\(model.currentTrack.format) · 44.1 kHz", tint: .gray) + } + } + TransportRow(model: model) + } + .frame(maxWidth: .infinity, alignment: .leading) + + SpectrumAnalyzer(model: model) + .frame(width: 400, height: 122) + } + .padding(EdgeInsets(top: 20, leading: 22, bottom: 18, trailing: 22)) + .background(LinearGradient(colors: [DS.teal.opacity(0.08), .clear], startPoint: .top, endPoint: .bottom)) + .overlay(alignment: .bottom) { Divider().opacity(0.4) } + } +} + +struct BadgeView: View { + enum Tint { case teal, gray } + let text: String + let tint: Tint + + var body: some View { + Text(text) + .font(.system(size: 9.5, weight: .bold)).kerning(0.5) + .foregroundStyle(tint == .teal ? DS.tealText : .white.opacity(0.6)) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(tint == .teal ? DS.teal.opacity(0.18) : .white.opacity(0.08), + in: RoundedRectangle(cornerRadius: 7)) + } +} + +struct TransportRow: View { + @ObservedObject var model: PlayerModel + + var body: some View { + HStack(spacing: 14) { + // Glass transport pill + HStack(spacing: 4) { + TransportButton(system: "backward.fill", size: 38) { model.previous() } + Button(action: model.togglePlay) { + Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 17, weight: .bold)).foregroundStyle(.white) + .frame(width: 48, height: 48) + .background( + LinearGradient(colors: [DS.tealLight, DS.tealDeep, DS.tealDarkest], + startPoint: .topLeading, endPoint: .bottomTrailing), + in: Circle() + ) + .shadow(color: DS.tealDeep.opacity(0.8), radius: 10, y: 6) + } + .buttonStyle(.plain) + TransportButton(system: "forward.fill", size: 38) { model.next() } + } + .padding(5) + .background(.white.opacity(0.06), in: Capsule()) + + Text(timeString(model.elapsed)).monoTime() + ScrubberSlider(value: Binding( + get: { model.elapsed / model.duration }, + set: { model.elapsed = $0 * model.duration } + )) + Text(timeString(model.duration)).monoTime() + } + } + + private func timeString(_ s: Double) -> String { + "\(Int(s) / 60):" + String(format: "%02d", Int(s) % 60) + } +} + +struct TransportButton: View { + let system: String + let size: CGFloat + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + Image(systemName: system) + .font(.system(size: 13, weight: .bold)).foregroundStyle(.white.opacity(0.9)) + .frame(width: size, height: size) + .background(hovering ? .white.opacity(0.08) : .clear, in: Circle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + } +} + +/// 4px slim slider with a 13px white knob — used by scrubber, gain, intensity. +struct ScrubberSlider: View { + @Binding var value: Double // 0…1 + var fillColor: Color = DS.tealLight + + var body: some View { + GeometryReader { geo in + let w = geo.size.width + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.14)).frame(height: 4) + Capsule() + .fill(LinearGradient(colors: [DS.tealDeep, fillColor], startPoint: .leading, endPoint: .trailing)) + .frame(width: max(0, w * value), height: 4) + Circle().fill(.white).frame(width: 13, height: 13) + .shadow(color: .black.opacity(0.55), radius: 3, y: 1) + .offset(x: w * value - 6.5) + } + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0).onChanged { g in + value = min(1, max(0, g.location.x / w)) + }) + } + .frame(height: 14) + } +} + +extension Text { + func monoTime() -> some View { + font(.system(size: 11, design: .monospaced)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.55)) + } +} + +// MARK: - Spectrum analyzer (teal → lime, boxed panel) + +struct SpectrumAnalyzer: View { + @ObservedObject var model: PlayerModel + + var body: some View { + ZStack(alignment: .bottom) { + RoundedRectangle(cornerRadius: 16).fill(.black.opacity(0.34)) + + // Bars — replace the animation with real FFT updates from the engine. + HStack(alignment: .bottom, spacing: 2) { + ForEach(model.spectrum.indices, id: \.self) { i in + let t = Double(i) / Double(model.spectrum.count - 1) + let base = DS.spectrumColor(at: t) + UnevenRoundedRectangle(topLeadingRadius: 1.5, topTrailingRadius: 1.5) + .fill(LinearGradient(colors: [base, base.darkened(0.18)], + startPoint: .top, endPoint: .bottom)) + .frame(maxWidth: .infinity) + .frame(height: nil) + .scaleEffect(y: model.isPlaying ? model.spectrum[i] : model.spectrum[i] * 0.4, + anchor: .bottom) + } + } + .padding(EdgeInsets(top: 10, leading: 12, bottom: 16, trailing: 12)) + .opacity(model.isPlaying ? 1 : 0.4) + .animation(.easeOut(duration: 0.12), value: model.spectrum) + + // Frequency scale + HStack { + Text("20 Hz"); Spacer(); Text("200"); Spacer(); Text("2 k"); Spacer(); Text("20 kHz") + } + .font(.system(size: 8.5, design: .monospaced)) + .foregroundStyle(.white.opacity(0.35)) + .padding(.horizontal, 12).padding(.bottom, 4) + } + .overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(.white.opacity(0.05))) + } +} + +// MARK: - Queue + +struct QueueColumn: View { + @ObservedObject var model: PlayerModel + @State private var filter = "" + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Text("Queue").font(.system(size: 14, weight: .bold)).foregroundStyle(.white.opacity(0.94)) + Text("\(model.tracks.count) tracks · 12:07:44") + .font(.system(size: 11, design: .monospaced)).monospacedDigit() + .foregroundStyle(.white.opacity(0.38)) + Spacer() + HStack(spacing: 7) { + Image(systemName: "magnifyingglass").font(.system(size: 10)) + TextField("Filter queue", text: $filter) + .textFieldStyle(.plain).font(.system(size: 11.5)).frame(width: 90) + } + .foregroundStyle(.white.opacity(0.42)) + .padding(.horizontal, 12).frame(height: 28) + .background(.white.opacity(0.07), in: Capsule()) + } + .padding(EdgeInsets(top: 14, leading: 22, bottom: 10, trailing: 16)) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(model.tracks.enumerated()), id: \.element.id) { i, track in + QueueRow(track: track, index: i, + isActive: i == model.currentIndex, + isPlaying: model.isPlaying) { model.play(track) } + } + } + .padding(EdgeInsets(top: 0, leading: 14, bottom: 12, trailing: 8)) + } + } + .frame(maxWidth: .infinity) + } +} + +struct QueueRow: View { + let track: Track + let index: Int + let isActive: Bool + let isPlaying: Bool + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 11) { + Group { + if isActive { + MiniEqualizer(animating: isPlaying) + } else { + Text("\(index + 1)") + .font(.system(size: 11, design: .monospaced)).monospacedDigit() + .foregroundStyle(.white.opacity(0.32)) + } + } + .frame(width: 18, alignment: .trailing) + + Text(track.title) + .font(.system(size: 13, weight: isActive ? .semibold : .regular)) + .foregroundStyle(isActive ? DS.tealBright : .white.opacity(0.82)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(track.format) + .font(.system(size: 9.5, weight: .bold)) + .foregroundStyle(isActive ? DS.tealText : .white.opacity(0.45)) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(isActive ? DS.teal.opacity(0.18) : .white.opacity(0.06), + in: RoundedRectangle(cornerRadius: 4)) + + Text(track.duration) + .font(.system(size: 11, design: .monospaced)).monospacedDigit() + .foregroundStyle(.white.opacity(0.45)) + .frame(width: 44, alignment: .trailing) + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 9) + .fill(isActive ? DS.teal.opacity(0.16) : hovering ? .white.opacity(0.05) : .clear) + ) + .overlay(RoundedRectangle(cornerRadius: 9) + .strokeBorder(isActive ? DS.teal.opacity(0.4) : .clear)) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help(track.absolutePath) // tooltip: full file path + } +} + +struct MiniEqualizer: View { + let animating: Bool + @State private var phase = false + + var body: some View { + HStack(alignment: .bottom, spacing: 2) { + ForEach(0 ..< 3, id: \.self) { i in + RoundedRectangle(cornerRadius: 1) + .fill(DS.tealLighter) + .frame(width: 2.5, height: phase ? 12 : 5) + .animation(.easeInOut(duration: 0.6 + Double(i) * 0.18) + .repeatForever(autoreverses: true).delay(Double(i) * 0.12), + value: phase) + } + } + .frame(height: 12) + .onAppear { phase = animating } + .onChange(of: animating) { phase = $0 } + } +} + +// MARK: - Inspector (floating glass, in flow) + +struct InspectorPanel: View { + @ObservedObject var model: PlayerModel + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + // Master gain + VStack(alignment: .leading, spacing: 8) { + InspectorLabel(title: "Master Gain", value: String(format: "%+.1f dB", model.masterGainDB)) + ScrubberSlider(value: Binding( + get: { (model.masterGainDB + 12) / 24 }, + set: { model.masterGainDB = $0 * 24 - 12 } + )) + } + // Intensity + VStack(alignment: .leading, spacing: 8) { + InspectorLabel(title: "Intensity", value: "\(Int(model.intensity * 100)) %") + ScrubberSlider(value: $model.intensity) + } + Divider().overlay(.white.opacity(0.09)) + + // Loudness meters + VStack(alignment: .leading, spacing: 9) { + Text("Loudness").inspectorTitle() + LoudnessMeter(label: "Integrated", value: model.integratedLUFS, range: -30 ... 0) + LoudnessMeter(label: "Short-term", value: model.shortTermLUFS, range: -30 ... 0) + LoudnessMeter(label: "True peak", value: model.truePeakDBTP, range: -12 ... 0, hot: model.truePeakDBTP > -1) + } + Divider().overlay(.white.opacity(0.09)) + + // Crossfeed + HStack { + Text("Crossfeed").font(.system(size: 12.5, weight: .medium)).foregroundStyle(.white.opacity(0.78)) + Spacer() + Toggle("", isOn: $model.crossfeedEnabled) + .toggleStyle(.switch).labelsHidden().controlSize(.small) + .disabled(!model.headphonesConnected) + } + if !model.headphonesConnected { + Text("Connect headphones to enable.") + .font(.system(size: 10.5)).foregroundStyle(.white.opacity(0.42)) + } + Spacer() + } + .padding(EdgeInsets(top: 18, leading: 16, bottom: 18, trailing: 16)) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18)) + .background(Color(hex: 0x1A1C20).opacity(0.66), in: RoundedRectangle(cornerRadius: 18)) + .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(.white.opacity(0.05))) + .shadow(color: .black.opacity(0.65), radius: 20, y: 8) + } +} + +struct InspectorLabel: View { + let title: String + let value: String + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title).inspectorTitle() + Spacer() + Text(value) + .font(.system(size: 11.5, design: .monospaced)).monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + } + } +} + +extension Text { + func inspectorTitle() -> some View { + font(.system(size: 11, weight: .bold)).kerning(0.4) + .textCase(.uppercase).foregroundStyle(.white.opacity(0.6)) + } +} + +struct LoudnessMeter: View { + let label: String + let value: Double + let range: ClosedRange + var hot = false + + private var fraction: Double { + (value - range.lowerBound) / (range.upperBound - range.lowerBound) + } + + var body: some View { + HStack(spacing: 10) { + Text(label).font(.system(size: 11.5)).foregroundStyle(.white.opacity(0.72)) + .frame(width: 62, alignment: .leading) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.1)) + Capsule() + .fill(LinearGradient( + colors: hot ? [DS.tealDeep, DS.tealLighter, DS.amber] : [DS.tealDeep, DS.tealLighter], + startPoint: .leading, endPoint: .trailing + )) + .frame(width: geo.size.width * max(0, min(1, fraction))) + } + } + .frame(height: 6) + Text(String(format: "%.1f", value)) + .font(.system(size: 10, design: .monospaced)).monospacedDigit() + .foregroundStyle(hot ? DS.amber : .white.opacity(0.85)) + .frame(width: 38, alignment: .trailing) + } + } +} + +// MARK: - Sample data & helpers + +extension Track { + static let samples: [Track] = [ + .init(title: "Bruno Mars, Adele, Ed Sheeran, Maroon 5, Dua Lipa — Billboard Top 50 This Week", duration: "174:12", format: "MP3", absolutePath: "~/Music/Library/Playlists/Billboard Top 50.mp3"), + .init(title: "Nirvana — Smells Like Teen Spirit (Official Music Video)", duration: "4:38", format: "MP3", absolutePath: "~/Music/Library/Rock/Nirvana/Smells Like Teen Spirit.mp3"), + .init(title: "Premasiri Kemadasa — Master Songs Collection", duration: "37:31", format: "MP3", absolutePath: "~/Music/Library/Classical/Kemadasa/Master Songs.mp3"), + .init(title: "Muse — Starlight (Official Music Video)", duration: "4:04", format: "MP3", absolutePath: "~/Music/Library/Rock/Muse/Starlight.mp3"), + .init(title: "Green Day — Boulevard Of Broken Dreams [4K Upgrade]", duration: "4:47", format: "MP3", absolutePath: "~/Music/Library/Rock/Green Day/Boulevard.mp3"), + .init(title: "Gotye — Somebody That I Used To Know (feat. Kimbra)", duration: "4:03", format: "MP3", absolutePath: "~/Music/Library/Pop/Gotye/Somebody.mp3"), + .init(title: "PSY — Gangnam Style (M/V)", duration: "4:12", format: "MP3", absolutePath: "~/Music/Library/Pop/PSY/Gangnam Style.mp3"), + .init(title: "Ed Sheeran, Rihanna, Selena Gomez — Billboard Hot 100", duration: "203:16", format: "MP3", absolutePath: "~/Music/Library/Playlists/Billboard Hot 100.mp3"), + .init(title: "LMFAO — Sexy and I Know It", duration: "3:23", format: "MP3", absolutePath: "~/Music/Library/Pop/LMFAO/Sexy and I Know It.mp3"), + .init(title: "Beautiful South — Perfect 10", duration: "3:35", format: "MP3", absolutePath: "~/Music/Library/Pop/Beautiful South/Perfect 10.mp3"), + .init(title: "Ed Sheeran — You Need Me, I Don't Need You", duration: "4:01", format: "MP3", absolutePath: "~/Music/Library/Pop/Ed Sheeran/You Need Me.mp3"), + .init(title: "Kasabian — Where Did All the Love Go (Video)", duration: "4:10", format: "MP3", absolutePath: "~/Music/Library/Rock/Kasabian/Where Did All the Love Go.mp3"), + .init(title: "Vanessa Carlton — A Thousand Miles", duration: "4:26", format: "MP3", absolutePath: "~/Music/Library/Pop/Vanessa Carlton/A Thousand Miles.mp3"), + .init(title: "Nickelback — Rockstar", duration: "4:15", format: "MP3", absolutePath: "~/Music/Library/Rock/Nickelback/Rockstar.mp3"), + .init(title: "Red Hot Chili Peppers — Scar Tissue [HD Upgrade]", duration: "3:41", format: "MP3", absolutePath: "~/Music/Library/Rock/RHCP/Scar Tissue.mp3"), + ] +} + +extension Color { + init(hex: UInt32) { + self.init(red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255) + } + + /// Darken by multiplying RGB (matches the prototype's 18% darkening). + func darkened(_ amount: Double) -> Color { + let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black + return Color(red: ns.redComponent * (1 - amount), + green: ns.greenComponent * (1 - amount), + blue: ns.blueComponent * (1 - amount)) + } +} + +extension NSColor { + convenience init(hex: UInt32) { + self.init(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, alpha: 1) + } + + func lerp(to other: NSColor, k: CGFloat) -> NSColor { + let a = usingColorSpace(.sRGB)!, b = other.usingColorSpace(.sRGB)! + return NSColor(srgbRed: a.redComponent + (b.redComponent - a.redComponent) * k, + green: a.greenComponent + (b.greenComponent - a.greenComponent) * k, + blue: a.blueComponent + (b.blueComponent - a.blueComponent) * k, + alpha: 1) + } +} + +#Preview { + NowPlayingView() +} diff --git a/docs/design/now-playing-7a/Player Layout Variants.dc.html b/docs/design/now-playing-7a/Player Layout Variants.dc.html new file mode 100644 index 0000000..4c157f7 --- /dev/null +++ b/docs/design/now-playing-7a/Player Layout Variants.dc.html @@ -0,0 +1,1684 @@ + + + + + + + + + + + + + + +
+
8Production pass on 7a — instrument-grade analyzer, resolved hierarchy, finished states
+
+ + +
+
8aProduction — analyzer gains gridlines + peak caps + 0 dB label, device pill gains sample rate, hero/queue separation resolved with a fading hairline, disabled crossfeed reads as disabled, unified badge radii.
+
+ +
+
+
+ + +
+
+ + + +
+
+
MacBook Pro Speakers 44.1 kHz
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ + +
+
+
+
Smells Like Teen Spirit
+
+ Nirvana + + ENHANCED · 20% + MP3 · 44.1 kHz +
+
+
+
+ + + +
+ 1:54 +
+ 4:38 +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
0 dB
+
20 Hz2002 k20 kHz
+
+
+ + +
+ + +
+ +
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.lead }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+ +
+
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
+
+
+
Loudness
+
+
Integrated
−15.1
+
Short-term
−14.2
+
True peak
−0.8
+
+
+
+
+
+ Crossfeed + +
+ Connect headphones to enable. +
+
+
+
+
+
+
+ +
+

Try next: "handoff package for 8a" · "dial the glass down" · "tweak anything"

+
+ +
+
7Liquid Glass adoption — 6a rebuilt in the macOS 26 material language
+
+ + +
+
7aLiquid Glass — content wallpaper glows through every layer; toolbar, transport pill, analyzer and inspector are true glass lenses with specular rims; capsule geometry and concentric radii throughout.
+
+ +
+
+
+ + +
+
+ + + +
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ + +
+
+
+
Smells Like Teen Spirit
+
+ Nirvana + + ENHANCED · 20% + MP3 · 44.1 kHz +
+
+
+ +
+ + + +
+ 1:54 +
+ 4:38 +
+
+ +
+
+ +
+
+
+
20 Hz2002 k20 kHz
+
+
+ + +
+ +
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.lead }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+ +
+
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
+
+
+
Loudness
+
+
Integrated
−15.1
+
Short-term
−14.2
+
True peak
−0.8
+
+
+
+
+
+ Crossfeed + +
+ Connect headphones to enable. +
+
+
+
+
+
+
+ +
+

Try next: "develop 7a into the working screen" · "handoff package for 7a" · "dial the glass down 20%"

+
+ +
+
6Production polish pass on 5a
+
+ + +
+
6aRefined — traffic lights + unified toolbar, aligned left edges, live-playing row indicator, analyzer gridlines + peak caps, tightened inspector rhythm.
+
+ +
+
+ + + +
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+
+
+
Smells Like Teen Spirit
+
+ Nirvana + + ENHANCED · 20% + MP3 · 44.1 kHz +
+
+
+
+ + + +
+ 1:54 +
+ 4:38 +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
0 dB
+
20 Hz2002 k20 kHz
+
+
+ +
+
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.lead }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+
+
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
+
+
+
Loudness
+
+
Integrated
−15.1
+
Short-term
−14.2
+
True peak
−0.8
+
+
+
+
+
+ Crossfeed + +
+ Connect headphones to enable. +
+
+
+
+
+
+
+ +
+

Try next: "develop 6a into the working screen" · "handoff package for 6a"

+
+ +
+
5Redesign of 4b — same glass language, overlap-proof flow layout
+
+ + +
+
5aGlass inspector, in-flow — queue and inspector are siblings in a real two-column grid; the inspector keeps its floating-glass look via margins + blur, but can never overlap the list.
+
+ +
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+
+
+
Smells Like Teen Spirit
+
+ Nirvana + ENHANCED 20% + MP3 · 44.1 kHz +
+
+
+
+ + + +
+ 1:54 +
+ 4:38 +
+
+
+
+ +
+
+
+
20 Hz2002 k20 kHz
+
+
+ +
+ +
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+ +
+
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
+
+
+
Loudness
+
+
Integrated
−15.1
+
Short-term
−14.2
+
True peak
−0.8
+
+
+
+
+ Crossfeed + +
+ Connect headphones to enable. +
+
+
+
+
+
+ +
+

Try next: "develop 5a into the working screen" · "tweak the inspector width" · "another variation"

+
+ +
+
4From 2c — Liquid Glass era (macOS 26/27): content leads, controls float as glass
+
+ + +
+
4aFloating capsule — the queue IS the content, edge to edge; transport + metadata live in one glass capsule floating at the bottom; analyzer stays a quiet panel top-right.
+
+ +
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+
+ INT −15.1 + ST −14.2 + PEAK −0.8 +
+
+ +
+ +
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ + + +
+
+
+ Smells Like Teen Spirit + Nirvana + ENHANCED 20% +
+
+ 1:54 +
+ 4:38 +
+
+
+
+
+ Gain +
+ +4.0 dB +
+
+ Intensity +
+ 20 % +
+
+
+
+
+
+ + +
+
4bGlass inspector — 2c's split kept, but bolder left-aligned hero typography; the DSP rail becomes a detached floating glass inspector with concentric radii and higher-contrast text.
+
+ +
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+
+
+
Smells Like Teen Spirit
+
+ Nirvana + ENHANCED 20% + MP3 · 44.1 kHz +
+
+
+
+ + + +
+ 1:54 +
+ 4:38 +
+
+
+
+ +
+
+
+
20 Hz2002 k20 kHz
+
+
+ +
+
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+ +
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
+
+
+
Loudness
+
+
Integrated
−15.1
+
Short-term
−14.2
+
True peak
−0.8
+
+
+
+
+ Crossfeed + +
+ Connect headphones to enable. +
+
+
+
+
+ +
+

Try next: "develop 4a into the working screen" · "4b with 4a's floating capsule" · "another round"

+
+ +
+
3From 2b + 2c — no album-art tile; typography carries the hero
+
+ + +
+
3aFull-bleed, type-led — 2b with the tile removed: big title sits straight on the spectrum, transport right.
+
+
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+
+ +
+
+
+
+
+
+
+
Smells Like Teen Spirit
+
Nirvana  ·  Enhanced 20%  ·  MP3  ·  44.1 kHz
+
+
+ + + +
+
+
+ 1:54 +
+ 4:38 +
+
+
+ +
+
+ Gain +
+ +4.0 dB +
+
+
+ Intensity +
+ 20 % +
+
+
+ INT −15.1 + ST −14.2 + PEAK −0.8 +
+
+
+ Crossfeed + +
+
+ +
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+
+
+ + +
+
3bSplit, analyzer-forward — 2c with the tile gone: text block tightens left, the boxed spectrum grows into a real analyzer panel with a dB scale.
+
+
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+
+
+
Smells Like Teen Spirit
+
+ Nirvana + ENHANCED 20% + MP3 + 44.1 kHz +
+
+
+
+ + + +
+ 1:54 +
+ 4:38 +
+
+ +
+
+
+
+
+
+
+
0 dB
+
−60
+
+ +
+
+
+
20 Hz2002 k20 kHz
+
+
+ +
+
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
+
+
+
Loudness
+
+
Integrated−15.1 LUFS
+
Short-term−14.2 LUFS
+
True peak−0.8 dBTP
+
+
+
+
+ Crossfeed + +
+ Connect headphones to enable. +
+
+
+
+ + +
+
3cHybrid — 2b's full-bleed hero (no tile, transport centered) over 2c's queue-left / rail-right stage.
+
+
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+
+ +
+
+
+
+
+
+
Smells Like Teen Spirit
+
Nirvana  ·  Enhanced 20%  ·  MP3 · 44.1 kHz
+
+
+ + + +
+
+ 1:54 +
+ 4:38 +
+
+
+ +
+
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
+
+
+
Loudness
+
+
Integrated
−15.1
+
Short-term
−14.2
+
True peak
−0.8
+
+
+
+
+ Crossfeed + +
+ Connect headphones to enable. +
+
+
+
+ +
+

Try next: "develop 3a into the working screen" · "3b with a taller analyzer" · "another round"

+
+ +
+
2Riffs on 1b — hero band variations
+
+ + +
+
2aHero band + instrument rail — 1a's horizontal meters replace the bare numbers.
+
+
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+
+
+
+
Smells Like Teen Spirit
+
Nirvana · Enhanced · MP3 · 44.1 kHz · Intensity 20%
+
+
+ + + +
+
+ 1:54 +
+ 4:38 +
+
+
+ +
+
+
+
+ +
+
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
+
+
+
Loudness
+
+
Integrated
−15.1 LUFS
+
Short-term
−14.2 LUFS
+
True peak
−0.8 dBTP
+
+
+
+
+ Crossfeed + +
+ Connect headphones to enable. +
+
+
+ Queue + 41 tracks +
+
Filter queue
+
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+
+
+
+ + +
+
2bFull-bleed hero — spectrum fills the whole band behind the transport; DSP collapses to one horizontal strip; queue gets full width.
+
+
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
Smells Like Teen Spirit
+
Nirvana · Enhanced · MP3 · 44.1 kHz
+
+
+ + + +
+
+
+ 1:54 +
+ 4:38 +
+
+
+ +
+
+ Gain +
+ +4.0 dB +
+
+
+ Intensity +
+ 20 % +
+
+
+ INT −15.1 + ST −14.2 + PEAK −0.8 +
+
+
+ Crossfeed + +
+
+ +
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+
+
+ + +
+
2cSplit hero — metadata + transport left, boxed spectrum right; DSP rail mirrors to the right edge, queue reads first.
+
+
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+
+
+
+
+
Smells Like Teen Spirit
+
Nirvana · Enhanced · MP3 · 44.1 kHz
+
+
+
+
+ + + +
+ 1:54 +
+ 4:38 +
+
+
+ +
+
+
+
+ +
+
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
+
+
+
Loudness
+
+
Integrated−15.1 LUFS
+
Short-term−14.2 LUFS
+
True peak−0.8 dBTP
+
+
+
+
+ Crossfeed + +
+ Connect headphones to enable. +
+
+
+
+ +
+

Try next: "develop 2b into the working screen" · "2a but with 2b's bigger hero" · "another round on 2c"

+
+ +
+
1Main player section — 3 layout directions (from your current build)
+
+ + +
+
1aConsole — left column becomes the instrument: hero + spectrum + transport + real meters. No bottom bar.
+
+ +
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+ +
+ +
+
+
+
Smells Like Teen Spirit
+
Nirvana · Official Music Video
+
+ ENHANCED + MP3 + 44.1 kHz +
+
+
+ +
+ +
+
+
+ +
+
+ + + +
+ 1:54 +
+ 4:38 +
+ +
+ Master Gain +
+ +4.0 dB +
+
+ +
+
Loudness
+
+
Integrated
−15.1 LUFS
+
Short-term
−14.2 LUFS
+
True peak
−0.8 dBTP
+
+
+ +
+ Intensity +
+ 20 % +
+
+ Crossfeed + + Headphones not connected +
+
+ +
+
+ Queue + 41 tracks · 12:07:44 +
+ + +
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+
+
+
+ + +
+
1bHero band — full-width now-playing strip with transport up top; queue gets the whole lower stage, DSP folds into a compact rail.
+
+ +
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+
+
+
+
Smells Like Teen Spirit
+
Nirvana · Enhanced · MP3 · 44.1 kHz · Intensity 20%
+
+
+ + + +
+
+ 1:54 +
+ 4:38 +
+
+ +
+ +
+
+
+
+ +
+ +
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
BYPASSFULL BLEND
+
+
+
+
Loudness
+
+
Integrated−15.1 LUFS
+
Short-term−14.2 LUFS
+
True peak−0.8 dBTP
+
+
+
+
+ Crossfeed + +
+ Connect headphones to enable. +
+ +
+
+ Queue + 41 tracks +
+
Filter queue
+
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+
+
+
+ + +
+
1cMeter bridge — queue is the page; narrow studio rail with vertical LUFS meters; transport docks bottom with edge-to-edge spectrum.
+
+ +
+
+
MacBook Pro Speakers
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ +
+ +
+ +
+
Loudness
+
+
+
+ −15.1 + INT +
+
+
+ −14.2 + ST +
+
+
+ −0.8 + PEAK +
+
+
+
+
+
Intensity20 %
+
+
+
+
Modules
+
+
Enhancement
+
Loudness Match
+
Crossfeed
+
+
+
+
Master+4.0 dB
+
+
+ +
+
+ Queue + 41 tracks · 12:07:44 +
+ Shuffle · Repeat · Autoplay +
+
+ +
+ {{ q.n }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+
+ +
+
+ +
+
+
+
+
+ + + +
+
+
Smells Like Teen Spirit — Nirvana
+
+ 1:54 +
+ 4:38 +
+
+
+
ENHANCED · 20%
+
+4.0 dB · 44.1 kHz
+
+
+
+
+
+ +
+

Try next: "merge 1a's meters into 1b" · "make 1c's rail collapsible" · "new directions"

+
+
+ + + diff --git a/docs/design/now-playing-7a/README.md b/docs/design/now-playing-7a/README.md new file mode 100644 index 0000000..94cde5b --- /dev/null +++ b/docs/design/now-playing-7a/README.md @@ -0,0 +1,63 @@ +# Handoff: Adaptive Sound — Now Playing, variant 7a "Liquid Glass" (RELEASE) + +> **⚠ Repo-specific: read `INTEGRATION.md` first.** It maps this design onto the actual `ramith/sound-engineering` architecture (AppShell/ChromeBar/NowPlayingBar, DesignSystem tokens, existing SpectrumColorPalette) and gives the safe 6-PR plan. The Swift file here is a visual reference, NOT a drop-in — the repo already has a governed UI layer. + +## What this is +Final released design of the **Now Playing** screen, to be implemented in the existing Swift/macOS codebase (Sources/AdaptiveSound) using **VS Code + Claude Code**. + +**Layout is identical to variant 5a** (already specified in `design_handoff_5a/README.md`, reproduced as the base spec below via `NowPlayingView.swift` + `now-playing-base.html`). **7a changes only the material language** — it adopts macOS Liquid Glass. Implement the 5a layout, then apply the Liquid Glass layer described here. + +Open `Player Layout Variants.dc.html` in a browser and find the card labeled **7a** (turn 7; see **8a** in turn 8 for the final production polish — 8a IS the release target: 7a + polish). + +## Recommended Claude Code process +1. Drop this folder into the repo (e.g. `docs/design/now-playing-7a/`). +2. In VS Code, ask Claude Code: *"Implement the Now Playing screen per docs/design/now-playing-7a/README.md. Use NowPlayingView.swift as the starting point, apply the Liquid Glass material layer, and wire PlayerModel to our audio engine."* +3. Review in small steps: layout first (5a base), then materials (glass layer), then engine wiring (FFT spectrum, EBU R128 loudness, device/tab state). +4. Keep the HTML files open side-by-side as the visual source of truth. + +## Base spec (layout, unchanged from 5a) +- `NowPlayingView.swift` — reference SwiftUI implementation: toolbar, hero (28px/800 title, badges, transport pill, scrubber), 400px analyzer, queue + fixed-width inspector as flow siblings, DS tokens incl. Teal→Lime spectrum mapping (`#1F9D8B #36C1AB #4FD2C0 #7FE3A8 #A8EC84 #C8F06A`, per-bar vertical darken 18%). +- `now-playing-base.html` — standalone browser-openable reference of the base layout. + +## Liquid Glass adoption layer (what 7a/8a adds) + +### 1. Ambient content glow (the light the glass refracts) +Window base `#0e1013`, radius 22. Three large blurred radial glows behind all content: +- top-left: teal `rgba(41,182,164,.28)`, ~720×560, blur 30 +- bottom-right: lime `rgba(200,240,106,.12)`, ~760×600, blur 34 +- mid-right: blue `rgba(79,178,214,.10)`, ~420×380, blur 28 +In SwiftUI: `Circle().fill(RadialGradient(...)).blur(radius:)` in a background ZStack. In production, these can instead sample the (future) album-art color. + +### 2. Glass recipe (apply to toolbar, transport pill, analyzer, inspector, filter pill, badges) +- Fill: dark translucent (`rgba(38,41,46,.5)` toolbar · `rgba(16,18,21,.42)` analyzer · `rgba(30,33,38,.5)` inspector · `rgba(255,255,255,.07-.09)` small controls) +- Backdrop: blur 20–28 **+ saturation boost 1.4–1.6** (`backdrop-filter: blur(26px) saturate(1.5)`). SwiftUI: `.background(.ultraThinMaterial)` approximates; for exact match use an `NSVisualEffectView` + saturation layer, or the OS glass-effect API where available. +- Specular top rim: `inset 0 1px 0 rgba(255,255,255,.14-.2)` +- Hairline: `inset 0 0 0 1px rgba(255,255,255,.04-.06)` +- Bottom light bleed: `inset 0 -12..-16px 24..32px -18..-24px rgba(255,255,255,.10-.14)` +- Drop shadow: `0 10..18px 30..44px -12..-14px rgba(0,0,0,.6-.65)` + +### 3. Geometry +- **Floating toolbar**: detached capsule, margin 14/16, height 52, radius 26 (contains traffic lights, app mark, device pill, tab capsule). +- Concentric radii: window 22 → inspector 22 → analyzer 20 → toolbar capsule 26 → pills = height/2 → queue rows 12 → badges 10-11. +- Alignment grid: floating panels inset **16px** from window edges; text gutter **26px** left; inspector top aligns with queue header. + +### 4. Component deltas vs base +- **Device pill**: add live sample rate readout ("44.1 kHz", 10.5px mono, 45% white) + hover state. Active tab: teal gradient `rgba(79,210,192,.95)→rgba(31,168,147,.95)` + inner top highlight + teal glow; inactive tabs get hover (bg white 7%, text 90%). +- **Scrubber/sliders**: 5px tracks with `inset 0 1px 2px rgba(0,0,0,.4)` (carved look); teal fill emits glow `0 0 8-10px rgba(63,208,186,.4-.5)`; 14px knobs with bottom inner shade. +- **Play button**: adds `inset 0 1.5px 0 rgba(255,255,255,.45)` top highlight + `inset 0 -6px 12px -8px rgba(0,0,0,.4)` bottom shade. +- **Analyzer**: dB gridlines (4 hairlines, white 4-6%), "0 dB" label top-right, **peak-hold caps** (2px, bar color at 50%, 4px above each bar), freq scale 20 Hz–20 kHz. +- **Hero title**: teal text-shadow halo `0 2px 16px rgba(41,182,164,.25)`. +- **ENHANCED badge**: pulsing 5px dot (1.6s opacity 1→.4), fixed 22px height; all badges capsule-height-consistent. +- **Dividers**: gradient-fade hairlines (`linear-gradient(90deg, transparent, rgba(255,255,255,.1-.12), transparent)`), not solid. +- **Disabled crossfeed**: whole block at 55% opacity, grey gradient knob, caption "Connect headphones to enable." +- **Queue rows**: radius 12, full file-path tooltip (`.help()`), active row = teal 16% fill + ring + animated 3-bar equalizer replacing the index. + +### 5. Motion & states +- Spectrum + row equalizer animate only while playing; pause → freeze + dim (opacity .4). +- Hovers: transport buttons (white 10%), rows (white 5%), pills (white ~10-12%). +- Respect Reduce Transparency (fall back to opaque fills) and Reduce Motion (no pulse/eq animation). + +## Files +- `Player Layout Variants.dc.html` + `support.js` — full exploration canvas; implement **8a** (= 7a + production polish). +- `NowPlayingView.swift` — base layout implementation to start from (apply the glass layer above). +- `now-playing-base.html` — standalone base-layout reference. diff --git a/docs/design/now-playing-7a/now-playing-base.html b/docs/design/now-playing-7a/now-playing-base.html new file mode 100644 index 0000000..c6352fb --- /dev/null +++ b/docs/design/now-playing-7a/now-playing-base.html @@ -0,0 +1,237 @@ + + + + + +Adaptive Sound — Now Playing (variant 5a) + + + +
+ +
+
+ +
+ + + + + +
+
+ + +
+
+
+
Smells Like Teen Spirit
+
+ Nirvana + ENHANCED 20% + MP3 · 44.1 kHz +
+
+
+
+ + + +
+ 1:54 +
+ 4:38 +
+
+
+
+
20 Hz2002 k20 kHz
+
+
+ + +
+
+
+ Queue + 41 tracks · 12:07:44 + +
+
+
+
+
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
+
+
+
Loudness
+
−15.1
+
−14.2
+
−0.8
+
+
+
Crossfeed
+ Connect headphones to enable. +
+
+
+
+ + + + diff --git a/docs/design/now-playing-7a/support.js b/docs/design/now-playing-7a/support.js new file mode 100644 index 0000000..ab2e6b0 --- /dev/null +++ b/docs/design/now-playing-7a/support.js @@ -0,0 +1,1466 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + @keyframes sc-veil-pulse{0%,100%{opacity:.4}50%{opacity:1}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine .73s ease infinite} + html.sc-dc-streaming::after{content:'';position:fixed;inset:0; + z-index:2147483646;pointer-events:none; + box-shadow:inset 0 0 90px rgba(217,119,87,.16),inset 0 0 22px rgba(217,119,87,.1); + animation:sc-veil-pulse 1.36s ease-in-out infinite} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:rgba(0,0,0,.7);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:rgba(0,0,0,.5);background:rgba(0,0,0,.05);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + html, body { print-color-adjust: exact; -webkit-print-color-adjust: exact; } + section, article, figure, table { break-inside: avoid; } + *, *::before, *::after { + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.adoptParsed(rootName, parsed); + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + return h(Root, entry.propOverrides || null); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, isComponent, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (isComponent) { + if (key.includes("-")) key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, true, host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) props[k] = g(vals); + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const url = el.getAttribute("from") || el.getAttribute("src") || el.getAttribute("import") || ""; + const kind = /\.(jsx|tsx)(\?|#|$)/i.test(url) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, true, host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const kids = hasContent ? walkChildren(el, host) : []; + const urlBindable = url.includes("{{"); + if (url && !urlBindable) host.loadExternal(kind, url); + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "name" || k === "from" || k === "src" || k === "import") { + continue; + } + props[k] = g(vals); + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const { propGetters, pseudoClasses } = collectProps(el, false, host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { key, "data-dc-tpl": tplId }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j))); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + this.__reconcileLogic(); + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + } catch (e) { + console.error(e); + registry.get(this.__name).logicError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const Next = registry.get(this.__name).Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic) + return; + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h("div", { className: "sc-logic-error" }, renderErr), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.26.4/babel.min.js"; + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = BABEL_URL; + s.crossOrigin = "anonymous"; + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + function load(kind, url) { + if (cache.has(url)) return; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = kind === "jsx" ? ensureBabel() : Promise.resolve(); + ready.then(() => fetch(url)).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/helmet.ts + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile }; + } + + // src/pseudo.ts + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const sel = pseudo === "before" || pseudo === "after" ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule(sel + "{" + css + "}", el.sheet.cssRules.length); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url) => external.load(kind, url), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + name + ".dc.html"; + fetch(url).then((res) => { + if (!res.ok) { + console.error( + "[dc-runtime] sibling fetch for <" + name + "/> failed:", + url, + "returned", + res.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res.text(); + }).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + "[dc-runtime] sibling fetch for <" + name + "/>:", + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + "[dc-runtime] sibling fetch for <" + name + "/> threw:", + url, + e + ) + ); + } + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: