diff --git a/Sources/AdaptiveSound/AudioEngineBridge.swift b/Sources/AdaptiveSound/AudioEngineBridge.swift index efe85c5..7dfdfdb 100644 --- a/Sources/AdaptiveSound/AudioEngineBridge.swift +++ b/Sources/AdaptiveSound/AudioEngineBridge.swift @@ -295,7 +295,7 @@ final class AudioEngineBridge: AudioPlaybackEngine, @unchecked Sendable { return LoudnessSnapshot( integratedLufs: readout.integratedLufs, shortTermLufs: readout.shortTermLufs, - peakDb: readout.peakDb + truePeakDb: readout.truePeakDb ) } diff --git a/Sources/AdaptiveSound/DesignSystem.swift b/Sources/AdaptiveSound/DesignSystem.swift index 1df028f..8151233 100644 --- a/Sources/AdaptiveSound/DesignSystem.swift +++ b/Sources/AdaptiveSound/DesignSystem.swift @@ -60,10 +60,11 @@ enum DesignSystem { static let blue = from(Palette.blue) /// Surfaces (elevation stack) — dark = pre-S9-T (#1E1E1E window, pre-D10); light = - /// first pass (gray base, white raised cards, darker inset). + /// first pass (gray base, white raised cards, darker inset). (`panel` had one + /// consumer, the footer band background, which S10.8 PR G replaced with the + /// styled-glass strata; the Kit `Palette.panel` token stays for the R4 audit.) 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 @@ -76,12 +77,13 @@ enum DesignSystem { 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. + /// Status FILL/indicator color (non-text 3:1): `statusWarning` #FF9F0A vibrant orange + /// (the Pure-fallback / interrupted dots). PR 6 (founder "split text vs fill"): + /// TEXT/glyph sites use the darker AA-legible variants below. (The `statusError` red + /// FILL lost its consumer when S10.8 PR E replaced the sample-peak CLIP bar with the + /// true-peak amber meter — `statusErrorText` still carries error TEXT in the sidebar; + /// the Kit `Palette.statusError` stays for the R4 audit.) 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) @@ -92,6 +94,28 @@ enum DesignSystem { /// TOK-04), so appearance-independent like `accent`. static let rowNowPlaying = from(Palette.rowNowPlaying) static let rowSelected = from(Palette.rowSelected) + + /// The small-control hover wash (S10.8 PR B): re-exports the badge fill — the same + /// white-8%/dark-6% wash the realigned mock uses for hovered chrome controls, so + /// hover states never mint a second near-identical value. + static let hoverWash = from(Palette.badgeFill) + + // S10.8 PR C — queue-header control chips (realigned `png/03`); audited R4-CHIP-01. + static let accentText = from(Palette.accentText) + static let controlHover = from(Palette.controlHover) + static let controlActiveFill = from(Palette.controlActiveFill) + static let segmentSelected = from(Palette.segmentSelected) + + /// S10.8 PR D — the playing row (realigned `png/04`); audited R4-ROW-01. + static let accentTitle = from(Palette.accentTitle) + /// The mini equalizer's bar teal — the iconFill gradient's bright stop, re-exported + /// (never a second #3FD0BA). + static let accentBright = from(Palette.iconFillTop) + + /// S10.8 PR E — the true-peak hot VALUE text (realigned `png/05`); audited + /// R4-METER-01. The hot FILL is `Gradient.meterHotFill` (which composes the Kit + /// `Palette.meterHot` stop inline), so there is no solid `meterHot` re-export. + static let meterHotText = from(Palette.meterHotText) } // MARK: Typography (semantic scale mapped to Dynamic Type text styles) @@ -118,6 +142,8 @@ enum DesignSystem { /// Uppercase section labels — pair with `.tracking(0.5).textCase(.uppercase)`. static let micro = SwiftUI.Font.system(.subheadline, weight: .semibold) // ~11 static let monoSmall = SwiftUI.Font.system(.subheadline, design: .monospaced) // ~11 mono + /// Micro mono captions (8a slider end-labels) — Dynamic-Type-mapped, never a fixed 9.5. + static let monoMicro = SwiftUI.Font.system(.caption2, design: .monospaced) // ~10 mono /// Compact now-playing pairing (footer transport / mini-player): title over subtitle. static let trackTitle = SwiftUI.Font.system(.headline) // ~13 semibold static let trackSubtitle = SwiftUI.Font.system(.subheadline) // ~11 @@ -153,6 +179,29 @@ enum DesignSystem { startPoint: .top, endPoint: .bottom ) + + /// The realigned horizontal teal fill (S10.8 PR E — `asTealMeter`): mid → bright, + /// leading → trailing. Shared by the loudness meters, the carved sliders, and the + /// footer scrubber's playing state — the same two iconFill stops, re-composed. + static let meterFill = LinearGradient( + gradient: SwiftUI.Gradient(colors: [ + Color.from(Palette.iconFillMid), + Color.from(Palette.iconFillTop), + ]), + startPoint: .leading, + endPoint: .trailing + ) + + /// The true-peak meter's HOT fill (> −1 dBTP): teal running into the amber tail + /// (realigned `png/05`). Stops: the bright teal → `meterHot`. + static let meterHotFill = LinearGradient( + gradient: SwiftUI.Gradient(colors: [ + Color.from(Palette.iconFillTop), + Color.from(Palette.meterHot), + ]), + startPoint: .leading, + endPoint: .trailing + ) } // MARK: Shell metrics (app-owned window chrome bands) @@ -259,5 +308,42 @@ enum DesignSystem { static let gripSymbol: CGFloat = 13 // drag-handle glyph point size static let gripHitWidth: CGFloat = 22 // grip hover/drag hit target static let gripHitHeight: CGFloat = 26 + /// Hover-revealed grip opacity (S10.8 PR C — realigned: no handles at rest). + static let gripHoverOpacity: Double = 0.45 + } + + // MARK: Loudness meter mapping (S10.8 PR E — the realigned meter rows, `png/05`) + + /// Display mapping for the inspector's loudness meter bars. Floors reproduce the mock's + /// fractions exactly: −15.9 LUFS → 62%, −24.3 → 41% (floor −42); −0.8 dBTP → 93%, + /// −3.2 → 74% (floor −12). The hot threshold equals the limiter's default −1 dBTP + /// ceiling — the meter warns exactly where the safety layer clamps. + enum Meters { + static let lufsFloor: Double = -42 + static let truePeakFloorDb: Double = -12 + static let hotThresholdDbtp: Double = -1.0 + /// "The true-peak row has a live measurement" — the bridge's −120 dB sentinel, with + /// margin. Independent of the LUFS `hasSignal` gate (a start-of-track transient + /// must never wait 400 ms of gated blocks to warn). + static let peakMeterFloorDb: Double = -110 + static let labelColumnWidth: CGFloat = 82 + } + + // MARK: Queue header metrics (S10.8 PR C — the realigned single-row header, `png/03`) + + /// Everything above the queue list collapses into ONE row of this height: title + count + + /// icon chips + the Up Next/Recent capsule pair + the right-aligned compact filter pill. + enum QueueHeader { + static let height: CGFloat = 32 + static let iconButton: CGFloat = 28 // square chip, Radius.control corners + static let iconSymbol: CGFloat = 12 + static let segmentHeight: CGFloat = 20 // + 2×segmentPadding = the 24pt pair + static let segmentPadding: CGFloat = 2 + /// The filter pill: 190pt ideal (the mock), compressing to min so the header row + /// survives the LAY-01 minimum queue width (90: leaves the count subtitle ~50pt at + /// the 880pt window's worst case — the subtitle is the designated truncation victim). + static let filterIdealWidth: CGFloat = 190 + static let filterMinWidth: CGFloat = 90 + static let filterHeight: CGFloat = 28 } } diff --git a/Sources/AdaptiveSound/DesignSystemGlass.swift b/Sources/AdaptiveSound/DesignSystemGlass.swift index 95ed6ce..30ad983 100644 --- a/Sources/AdaptiveSound/DesignSystemGlass.swift +++ b/Sources/AdaptiveSound/DesignSystemGlass.swift @@ -85,11 +85,13 @@ struct CarvedGroove: View { 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). + /// The progress fill (S10.8 PR E: the realigned mid→bright teal gradient by default — + /// sliders, meters, and the playing scrubber share it; the scrubber's paused/ + /// interrupted states pass their solid state colors instead). + var fillStyle: AnyShapeStyle = .init(DesignSystem.Gradient.meterFill) + /// Whether the fill carries the dark-only teal glow (off when the fill isn't the accent + /// family — 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 @@ -116,7 +118,7 @@ struct CarvedGroove: View { .frame(height: height) Capsule() - .fill(fillColor) + .fill(fillStyle) .frame(width: geo.size.width * clamped, height: height) .shadow(color: (dark && glow) ? SwiftUI.Color(token: GlassDecor.sliderGlowDark) : .clear, radius: 5) @@ -173,6 +175,146 @@ struct CarvedTrack: View { } } +// MARK: - Inspector card glow (S10.8 PR E — realigned `png/05`) + +/// The teal radial glow behind/below the floating inspector card: rendered only when the +/// glow field itself is visible (dark + no Reduce Transparency — `GlowFieldGate`), and +/// extending past the card's bottom so the empty area under the hugged card reads +/// intentional. Appearance-gated, so it lives in this sanctioned file. +struct InspectorCardGlow: View { + var body: some View { + GlowFieldGate { + RadialGradient( + colors: [SwiftUI.Color(token: GlassDecor.inspectorGlowDark), .clear], + center: .center, + startRadius: 0, + endRadius: CGFloat(GlassDecor.inspectorGlowRadius) + ) + .padding(.bottom, -CGFloat(GlassDecor.inspectorGlowBleed)) + .blur(radius: CGFloat(GlassDecor.inspectorGlowBlur)) + } + } +} + +// MARK: - Capsule tab strip visuals (S10.8 PR B — the realigned 8a tab selector) + +/// The tab strip's carved track: the `tabTrack` token fill + the shared carved top inner +/// shade (the same inset-shadow cue as `CarvedGroove` — one carved grammar, two consumers). +/// Appearance-aware, so it lives in this sanctioned file. +struct TabTrackCapsule: View { + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let dark = colorScheme == .dark + Capsule() + .fill(SwiftUI.Color(token: Palette.tabTrack.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: 2) + } + .clipShape(Capsule()) + } +} + +/// A teal "glossy button" fill for any insettable shape: the brand `iconFill` gradient +/// (the realigned "teal button" IS this gradient — byte-identical stops), the shared +/// specular top rim, and a dark-only teal glow (grammar rule 6: light drops emissive +/// cues). Consumers: the active tab capsule (PR B) and the footer play circle (PR G). +struct TealGloss: View { + var shape: GlossShape + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let appearance: TokenAppearance = colorScheme == .dark ? .dark : .light + shape + .fill(DesignSystem.Gradient.iconFill) + .overlay { + shape.strokeBorder( + LinearGradient( + colors: [SwiftUI.Color(token: GlassDecor.rim.value(for: appearance)), .clear], + startPoint: .top, endPoint: .center + ), + lineWidth: 1 + ) + } + .shadow(color: appearance == .dark ? SwiftUI.Color(token: GlassDecor.tabActiveGlowDark) : .clear, + radius: CGFloat(GlassDecor.tabActiveGlowRadius), + x: 0, + y: CGFloat(GlassDecor.tabActiveGlowOffsetY)) + } +} + +/// The active tab's teal capsule (S10.8 PR B) — the capsule instance of `TealGloss`. +struct ActiveTabCapsule: View { + var body: some View { + TealGloss(shape: Capsule()) + } +} + +// MARK: - Chrome band styled glass (S10.8 PR G — realigned `png/01` + `png/06`) + +/// The app-owned chrome bands' "styled glass" (founder decision 2: fill strata, NOT real +/// blur — nothing scrolls behind these bands). Dark: the window base + a sheen gradient +/// lit from `lightFrom`, a 1px specular line on the band's TOP edge, and (header only) a +/// dark seam on its bottom edge. Light: the plain window + hairline seams — the grammar +/// drops emissive cues. Owns what AppShell's `.background` + `Hairline` overlays did. +struct ChromeBandModifier: ViewModifier { + /// `.top` for the header (lit from its top edge), `.bottom` for the footer. + var lightFrom: UnitPoint = .top + + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { + let dark = colorScheme == .dark + content + .background { + ZStack { + DesignSystem.Color.window + if dark { + LinearGradient( + colors: [SwiftUI.Color(token: GlassDecor.bandSheenStrong), + SwiftUI.Color(token: GlassDecor.bandSheenWeak)], + startPoint: lightFrom, + endPoint: lightFrom == .top ? .bottom : .top + ) + } + } + } + // The specular line ALWAYS sits on the band's top edge (the edge that catches + // light — the realign guide's StyledGlassBar contract). Light keeps only the + // SEPARATING hairlines the bands had before (footer top; header top is the + // window edge — nothing to separate). + .overlay(alignment: .top) { + if dark || lightFrom == .bottom { + Rectangle() + .fill(dark ? SwiftUI.Color(token: GlassDecor.bandSpecularDark) + : DesignSystem.Color.hairline) + .frame(height: 1) + } + } + .overlay(alignment: .bottom) { + if lightFrom == .top { // header: dark seam against the content below + Rectangle() + .fill(dark ? SwiftUI.Color(token: GlassDecor.bandSeamDark) + : DesignSystem.Color.hairline) + .frame(height: 1) + } + } + } +} + +extension View { + /// Apply the realigned chrome-band treatment (see `ChromeBandModifier`). + func chromeBand(lightFrom: UnitPoint) -> some View { + modifier(ChromeBandModifier(lightFrom: lightFrom)) + } +} + /// 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). diff --git a/Sources/AdaptiveSound/Loudness/LoudnessSnapshot.swift b/Sources/AdaptiveSound/Loudness/LoudnessSnapshot.swift index 8102b37..c69ea6b 100644 --- a/Sources/AdaptiveSound/Loudness/LoudnessSnapshot.swift +++ b/Sources/AdaptiveSound/Loudness/LoudnessSnapshot.swift @@ -2,15 +2,17 @@ import Foundation /// A point-in-time loudness readout for the UI meters, produced off the audio /// thread by the BS.1770-5 `LufsMeter` (via the C bridge) and polled by the view -/// model. All values are LUFS except `peakDb` (sample-peak dBFS). +/// model. All values are LUFS except `truePeakDb` — inter-sample TRUE peak in +/// dBTP (8× polyphase ISP, the shared `TruePeakKernel`; sample-peak before +/// S10.8 PR E — the "True peak" meter label is honest because of this field). struct LoudnessSnapshot: Equatable { var integratedLufs: Double var shortTermLufs: Double - var peakDb: Double + var truePeakDb: Double /// Sentinel before any measurement exists (engine stopped / silence). static let unmeasured = LoudnessSnapshot( - integratedLufs: -200, shortTermLufs: -200, peakDb: -120 + integratedLufs: -200, shortTermLufs: -200, truePeakDb: -120 ) /// True once the meter has produced a real integrated value. diff --git a/Sources/AdaptiveSound/TabSelection.swift b/Sources/AdaptiveSound/TabSelection.swift index 50d5306..db57626 100644 --- a/Sources/AdaptiveSound/TabSelection.swift +++ b/Sources/AdaptiveSound/TabSelection.swift @@ -12,14 +12,7 @@ enum TabSelection: String, CaseIterable { var id: String { rawValue } - - var icon: String { - switch self { - case .nowPlaying: "play.circle.fill" - case .library: "square.grid.2x2" - case .eq: "slider.horizontal.3" - case .monitoring: "waveform.and.magnifyingglass" - case .settings: "gearshape.fill" - } - } + // No `icon`: the realigned tab strip (S10.8 PR B) is text-only capsules — the old + // per-tab SF Symbols were for the retired segmented Picker. Re-add with a consumer if + // a future surface (e.g. a menu) needs tab glyphs. } diff --git a/Sources/AdaptiveSound/UI/FormatBadgeView.swift b/Sources/AdaptiveSound/UI/FormatBadgeView.swift index 0f33ba3..c3fd695 100644 --- a/Sources/AdaptiveSound/UI/FormatBadgeView.swift +++ b/Sources/AdaptiveSound/UI/FormatBadgeView.swift @@ -8,13 +8,20 @@ struct FormatBadgeView: View { let format: String var isSelected: Bool = false + /// 8a metric (18pt chip) — @ScaledMetric so larger text sizes grow the chip instead of + /// clipping (break-it finding 3: the fixed frame regressed the old padding-based chip). + @ScaledMetric(relativeTo: .subheadline) private var chipHeight: CGFloat = 18 + var body: some View { + // 8a metrics (deviations §3): 18pt capsule-ish chip, radius = height/2. + // Selected/current tint (S10.8 PR D, realigned `png/04`): the audited chip pair — + // accentText on the accent-derived controlActiveFill (R4-CHIP-01). Text(format) .font(DesignSystem.Font.micro) .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(isSelected ? Color.asAccent.opacity(0.2) : Color.asCard) - .foregroundStyle(isSelected ? Color.asAccent : Color.asLabelSecond) - .clipShape(.rect(cornerRadius: 4, style: .continuous)) + .frame(height: chipHeight) + .background(isSelected ? DesignSystem.Color.controlActiveFill : Color.asCard) + .foregroundStyle(isSelected ? DesignSystem.Color.accentText : Color.asLabelSecond) + .clipShape(.rect(cornerRadius: chipHeight / 2, style: .continuous)) } } diff --git a/Sources/AdaptiveSound/UI/Loudness/LoudnessMetersView.swift b/Sources/AdaptiveSound/UI/Loudness/LoudnessMetersView.swift index 6297e33..c99cfbb 100644 --- a/Sources/AdaptiveSound/UI/Loudness/LoudnessMetersView.swift +++ b/Sources/AdaptiveSound/UI/Loudness/LoudnessMetersView.swift @@ -1,15 +1,24 @@ +import DesignTokenKit import SwiftUI // MARK: - Loudness Meters -/// Read-only BS.1770-5 loudness readout (integrated + short-term LUFS) and a -/// sample-peak bar, driven by `AudioViewModel.loudness` (measured on the playback -/// tap by the C++ LufsMeter). Replaces the previously-hardcoded "Active Modules". +/// Read-only BS.1770-5 loudness readout, realigned (S10.8 PR E — `png/05`): three rows of +/// label + slim gradient meter + right-aligned mono value. Integrated / Short-term are +/// LUFS; the third row is inter-sample TRUE peak in dBTP (the label is honest — the C +/// bridge now runs the limiter's 8× polyphase ISP kernel, founder decision 3). Above +/// −1 dBTP the meter tail and value turn amber (`meterHot`, audited R4-METER-01). struct LoudnessMetersView: View { @Environment(AudioViewModel.self) private var viewModel var body: some View { let snapshot = viewModel.loudness + // The true-peak row keys on its OWN floor, never on `hasSignal` (break-it finding: + // integrated LUFS needs ≥400 ms of gated blocks, so a hot transient at track start + // — exactly when clipping risk peaks — would be suppressed by the LUFS gate; the + // old peak bar was always live). + let peakLive = snapshot.truePeakDb > DesignSystem.Meters.peakMeterFloorDb + let hot = peakLive && snapshot.truePeakDb > DesignSystem.Meters.hotThresholdDbtp VStack(alignment: .leading, spacing: 8) { Text("Loudness") .font(.caption.weight(.semibold)) @@ -17,96 +26,86 @@ struct LoudnessMetersView: View { .textCase(.uppercase) .foregroundStyle(Color.asLabelSecond) - LoudnessReadoutRow(label: "Integrated", lufs: snapshot.integratedLufs, - hasSignal: snapshot.hasSignal) - LoudnessReadoutRow(label: "Short-term", lufs: snapshot.shortTermLufs, - hasSignal: snapshot.hasSignal) - - PeakMeterBar(peakDb: snapshot.peakDb) + MeterRow(label: "Integrated", + valueText: lufsText(snapshot.integratedLufs, hasSignal: snapshot.hasSignal), + fraction: lufsFraction(snapshot.integratedLufs, hasSignal: snapshot.hasSignal), + hot: false, + spokenSuffix: "") + MeterRow(label: "Short-term", + valueText: lufsText(snapshot.shortTermLufs, hasSignal: snapshot.hasSignal), + fraction: lufsFraction(snapshot.shortTermLufs, hasSignal: snapshot.hasSignal), + hot: false, + spokenSuffix: "") + MeterRow(label: "True peak", + valueText: truePeakText(snapshot, live: peakLive, hot: hot), + fraction: truePeakFraction(snapshot, live: peakLive), + hot: hot, + // Non-color cue for the hot state (A-M5 posture): the spoken value + // names the ceiling; sighted users get the ▲ glyph (shape, not hue) + // beside the amber tail + value. + spokenSuffix: hot ? ", above the −1 dBTP ceiling" : "") } .accessibilityElement(children: .contain) .accessibilityLabel("Loudness meters") } -} -// MARK: - LUFS readout row - -private struct LoudnessReadoutRow: View { - let label: String - let lufs: Double - let hasSignal: Bool - - private var valueText: String { + private func lufsText(_ lufs: Double, hasSignal: Bool) -> String { guard hasSignal else { return "—" } return "\(lufs.formatted(.number.precision(.fractionLength(1)))) LUFS" } - var body: some View { - HStack(spacing: 6) { - Text(label) - .font(DesignSystem.Font.caption) - .foregroundStyle(Color.asLabelSecond) - Spacer() - Text(valueText) - .font(DesignSystem.Font.monoSmall.weight(.medium)) - .monospacedDigit() - .foregroundStyle(Color.asLabel) - } - .accessibilityElement(children: .combine) - .accessibilityLabel("\(label): \(valueText)") + /// Meter fill for a LUFS value: 0 at the −42 floor → 1 at 0 LUFS (the mock's mapping). + private func lufsFraction(_ lufs: Double, hasSignal: Bool) -> Double { + guard hasSignal else { return 0 } + let floor = DesignSystem.Meters.lufsFloor + return min(max((lufs - floor) / (0 - floor), 0), 1) } -} -// MARK: - Sample-peak bar (dBFS) - -private struct PeakMeterBar: View { - let peakDb: Double - - private let floorDb: Double = -60 - private let clipDb: Double = -1 - - /// 0…1 fill from the floor up to 0 dBFS. - private var fraction: Double { - min(max((peakDb - floorDb) / (0 - floorDb), 0), 1) + private func truePeakText(_ snapshot: LoudnessSnapshot, live: Bool, hot: Bool) -> String { + guard live else { return "—" } + let value = "\(snapshot.truePeakDb.formatted(.number.precision(.fractionLength(1)))) dBTP" + // ▲ = the VISIBLE non-color over-ceiling cue (A-M5 — the retired "CLIP" word's + // successor: a shape a colorblind user reads without the amber). + return hot ? "▲ \(value)" : value } - private var isHot: Bool { - peakDb >= clipDb + private func truePeakFraction(_ snapshot: LoudnessSnapshot, live: Bool) -> Double { + guard live else { return 0 } + let floor = DesignSystem.Meters.truePeakFloorDb + return min(max((snapshot.truePeakDb - floor) / (0 - floor), 0), 1) } +} + +// MARK: - Meter row (label · slim gradient bar · mono value) + +private struct MeterRow: View { + let label: String + let valueText: String + let fraction: Double + let hot: Bool + let spokenSuffix: String var body: some View { - HStack(spacing: 6) { - Text("Peak") + HStack(spacing: 10) { + Text(label) .font(DesignSystem.Font.caption) .foregroundStyle(Color.asLabelSecond) + .frame(width: DesignSystem.Meters.labelColumnWidth, alignment: .leading) - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(Color.asCard) - Capsule() - .fill(isHot ? DesignSystem.Color.statusError : Color.asAccent) - .frame(width: geo.size.width * CGFloat(fraction)) - } - } - .frame(height: 4) + // The shared carved groove (same primitive as the sliders/scrubber), no glow — + // a readout, not a control. Hot swaps to the teal→amber tail gradient. + CarvedGroove(fillFraction: fraction, + fillStyle: hot ? AnyShapeStyle(DesignSystem.Gradient.meterHotFill) + : AnyShapeStyle(DesignSystem.Gradient.meterFill), + glow: false) + .frame(height: CGFloat(GlassDecor.carvedTrackHeight)) - // Non-color clip cue (A-M5): the bar turning red is the only over-level signal - // otherwise — invisible to colorblind + VoiceOver users. The word "CLIP" carries it. - if isHot { - Text("CLIP") - .font(DesignSystem.Font.micro.weight(.bold)) - .foregroundStyle(DesignSystem.Color.statusErrorText) // text → AA variant (fill above stays vivid) - } + Text(valueText) + .font(DesignSystem.Font.monoSmall.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(hot ? DesignSystem.Color.meterHotText : Color.asLabel) } .accessibilityElement(children: .ignore) - .accessibilityLabel("Peak level") - .accessibilityValue(peakValueDescription) - } - - /// Spoken peak level, with the clip state folded into the VoiceOver value (A-M5). - private var peakValueDescription: String { - guard peakDb > -100 else { return "silent" } - let level = "\(peakDb.formatted(.number.precision(.fractionLength(1)))) dBFS" - return isHot ? "\(level), clipping" : level + .accessibilityLabel("\(label): \(valueText)\(spokenSuffix)") } } diff --git a/Sources/AdaptiveSound/UI/NowPlaying/HeroBand.swift b/Sources/AdaptiveSound/UI/NowPlaying/HeroBand.swift index d48a9e8..a608706 100644 --- a/Sources/AdaptiveSound/UI/NowPlaying/HeroBand.swift +++ b/Sources/AdaptiveSound/UI/NowPlaying/HeroBand.swift @@ -59,6 +59,7 @@ struct HeroBand: View { // The badge row reserves its space even when hidden (empty state) so first // play never reflows the hero (§5). SignalBadgeRow(info: viewModel.signalPath, + format: track?.format, isPlaying: viewModel.isPlaying, reduceMotion: reduceMotion, badgeHeight: badgeHeight) @@ -72,10 +73,15 @@ struct HeroBand: View { // 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). +/// The §5 state mapping, realigned to EXACTLY TWO core chips (S10.8 PR F — `png/02`): +/// the teal path chip (`● ENHANCED · 20%` — dot + path + live intensity in ONE string) and +/// the grey format·rate chip (`MP3 · 48 kHz` — the format segment is NEW to the hero). +/// The extra STATES stay: PURE keeps its monochrome chip, crossfeed and the Pure-fallback +/// warning keep theirs, interrupted replaces the row. All colors/fills are audited tokens. private struct SignalBadgeRow: View { let info: SignalPathInfo + /// The current track's file format ("MP3", "FLAC") — nil hides the segment. + let format: String? let isPlaying: Bool let reduceMotion: Bool let badgeHeight: CGFloat @@ -92,18 +98,10 @@ private struct SignalBadgeRow: View { } } else { pathBadge - BadgeCapsule(height: badgeHeight) { - Text(info.formattedRate).badgeText(DesignSystem.Color.label) - } + formatRateBadge // 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)") @@ -125,32 +123,81 @@ private struct SignalBadgeRow: View { .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 + /// PURE (accent dot, monochrome chip — untouched by the realign) vs ENHANCED: the + /// realigned TEAL chip carrying the pulsing dot + the live intensity in one string. + @ViewBuilder private var pathBadge: some View { + if info.path == .pure { + BadgeCapsule(height: badgeHeight) { + Circle().fill(DesignSystem.Color.accent).frame(width: 5, height: 5) + Text("PURE") + .badgeText(DesignSystem.Color.label) + .tracking(0.5) + } + } else { + TealBadgeCapsule(height: badgeHeight) { + pulsingDot + Text(enhancedText) + .badgeText(DesignSystem.Color.accentText) + .tracking(0.5) + } + } + } + + /// One string, live values (realigned): `ENHANCED · 20%`; plain `ENHANCED` at 0% + /// (bit-perfect blend — a zero reads as noise, not information). + private var enhancedText: String { + let percent = Int((info.intensityLinear * 100).rounded()) + return percent > 0 ? "ENHANCED · \(percent)%" : "ENHANCED" + } + + /// The 6pt dot: pulses via the §3.4 conditional phaseAnimator (unmounting IS the + /// deterministic stop); frozen at full opacity when paused or under Reduce Motion. + /// Amber when Pure fell back (the warning chip beside it carries the words). + @ViewBuilder private var pulsingDot: some View { + let dot = Circle() + .fill(info.fellBackToEnhanced ? DesignSystem.Color.statusWarning + : DesignSystem.Color.accentBright) + .frame(width: 6, height: 6) + if pulseIsActive(isPlaying: isPlaying, reduceMotion: reduceMotion) { + dot.phaseAnimator([1.0, GlassDecor.pulseDimOpacity]) { view, opacity in + view.opacity(opacity) + } animation: { _ in + .easeInOut(duration: GlassDecor.pulseHalfCycleSeconds) } - Text(pure ? "PURE" : "ENHANCED") - .badgeText(DesignSystem.Color.label) - .tracking(0.5) + } else { + dot + } + } + + /// `MP3 · 48 kHz` — the grey mono chip. Text stays the PRIMARY label (not the mock's + /// white-60%): R4-BADGE-01's standing rule — dimmed hierarchy on a badge over the teal + /// glow core fails AA; hierarchy on a chip comes from the capsule, not dimmed text. + private var formatRateBadge: some View { + BadgeCapsule(height: badgeHeight) { + Text([format, info.formattedRate].compactMap(\.self).joined(separator: " · ")) + .font(DesignSystem.Font.monoSmall.weight(.semibold)) + .foregroundStyle(DesignSystem.Color.label) } } } +// MARK: - Teal badge capsule (the realigned ENHANCED chip) + +/// The accent-tinted chip: the audited `controlActiveFill` + accent ring pair (same family +/// as the queue header's toggled-on chips; text/glyphs on it are `accentText`, R4-CHIP-02). +private struct TealBadgeCapsule: View { + let height: CGFloat + @ViewBuilder var content: Content + + var body: some View { + HStack(spacing: 5) { content } + .padding(.horizontal, 11) + .frame(height: height) + .background(Capsule().fill(DesignSystem.Color.controlActiveFill)) + .overlay(Capsule().strokeBorder(DesignSystem.Color.accent.opacity(0.28), lineWidth: 1)) + } +} + // MARK: - Badge capsule private struct BadgeCapsule: View { @@ -168,7 +215,7 @@ private struct BadgeCapsule: View { private extension Text { func badgeText(_ color: SwiftUI.Color) -> Text { - font(DesignSystem.Font.micro).foregroundColor(color) + font(DesignSystem.Font.micro).foregroundStyle(color) } } diff --git a/Sources/AdaptiveSound/UI/NowPlaying/InspectorColumn.swift b/Sources/AdaptiveSound/UI/NowPlaying/InspectorColumn.swift index dedc5d8..2bb2a77 100644 --- a/Sources/AdaptiveSound/UI/NowPlaying/InspectorColumn.swift +++ b/Sources/AdaptiveSound/UI/NowPlaying/InspectorColumn.swift @@ -12,13 +12,20 @@ import SwiftUI struct InspectorColumn: View { @Environment(AudioViewModel.self) private var viewModel + /// Measured height of the (padded) content — the FLOATING card (S10.8 PR E) hugs this + /// instead of stretching to the window bottom; when the region offers less, the + /// ScrollView scrolls inside the chrome exactly as before. + @State private var contentHeight: CGFloat = 0 + var body: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: DesignSystem.Spacing.medium) { MasterGainSliderView() ReimagineSectionView() signalDetail + sectionDivider LoudnessMetersView() + sectionDivider HeadphonesSectionView() } .padding([.horizontal, .top], 14) @@ -29,16 +36,36 @@ struct InspectorColumn: View { // CROSS the run transiently while scrolling — accepted, same class as text // passing under the seam feather.) .padding(.bottom, CGFloat(GlassDecor.bleedHeight)) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + contentHeight = height + } } + // The hug: cap the panel at its content height (top-aligned in the region the + // HStack offers) — never a fixed height on the panel (guide E1). Zero means "not + // yet measured" → behave as before for one layout pass. + .frame(maxHeight: contentHeight > 0 ? contentHeight : .infinity, alignment: .top) // 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. + // render square into the corner cutouts — and this panel is still 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)) + // The teal radial glow behind/below the card (dark-only via GlowFieldGate) — sits + // BEHIND the fill strata, bleeding past the bottom edge. + .background { InspectorCardGlow() } .frame(width: CGFloat(NowPlayingLayout.inspectorWidth)) + .frame(maxHeight: .infinity, alignment: .top) + } + + /// Realigned section divider: a 1px hairline run between the inspector's sections. + private var sectionDivider: some View { + Rectangle() + .fill(Color.asHairline) + .frame(height: 1) } /// Decoder + bit-depth detail (§5: the audiophile home for what left the hero badges). @@ -99,13 +126,15 @@ struct ReimagineSectionView: View { .disabled(isPureBypassed) .help(bvm.intensity == 0 ? "0 % = bit-perfect bypass" : "") + // 8a caption row: micro mono under the slider (nearest audited token to the + // spec's 32%-white is labelTertiary — a bespoke 32% token isn't worth the pair). HStack { Text("Bypass") - .font(DesignSystem.Font.trackSubtitle) + .font(DesignSystem.Font.monoMicro) .foregroundStyle(Color.asLabelTertiary) Spacer() Text("Full Blend") - .font(DesignSystem.Font.trackSubtitle) + .font(DesignSystem.Font.monoMicro) .foregroundStyle(Color.asLabelTertiary) } } @@ -132,12 +161,14 @@ struct HeadphonesSectionView: View { .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.)") + // One line + tooltip (deviations §4): the full rationale lives in .help, not + // permanently on screen. + Text("Connect headphones to enable.") .font(DesignSystem.Font.trackSubtitle) .foregroundStyle(Color.asLabelTertiary) - .fixedSize(horizontal: false, vertical: true) + .help("On a speaker device the only consequence of crossfeed is a mild, " + + "reversible centre-image change — crossfeed is offered here, not " + + "auto-applied.") } // ONE row (founder, PR-5 screenshot round): toggle leading, strength picker @@ -152,7 +183,7 @@ struct HeadphonesSectionView: View { } } } - .opacity(isEnabled ? 1 : 0.5) + .opacity(isEnabled ? 1 : 0.5) // realigned disabled-block opacity (guide E1: 50%) } } diff --git a/Sources/AdaptiveSound/UI/NowPlaying/MasterGainSliderView.swift b/Sources/AdaptiveSound/UI/NowPlaying/MasterGainSliderView.swift index 2dbbb37..7f3c0bc 100644 --- a/Sources/AdaptiveSound/UI/NowPlaying/MasterGainSliderView.swift +++ b/Sources/AdaptiveSound/UI/NowPlaying/MasterGainSliderView.swift @@ -19,7 +19,8 @@ struct MasterGainSliderView: View { Spacer() let dbValue = Double(vm.masterGain) * 20 - 10 - Text("\(Text(dbValue, format: .number.precision(.fractionLength(1)))) dB") + // SIGNED readout (8a: "+4.0 dB" — gain direction matters on an audio control). + Text("\(Text(dbValue, format: .number.precision(.fractionLength(1)).sign(strategy: .always()))) dB") .font(DesignSystem.Font.monoSmall.weight(.semibold)) .foregroundStyle(Color.asLabelSecond) } @@ -27,7 +28,7 @@ struct MasterGainSliderView: View { CarvedSlider( value: $vm.masterGain, accessibilityLabel: "Master Gain", - accessibilityValueText: String(format: "%.1f decibels", Double(vm.masterGain) * 20 - 10) + accessibilityValueText: String(format: "%+.1f decibels", Double(vm.masterGain) * 20 - 10) ) } } diff --git a/Sources/AdaptiveSound/UI/Playlist/PlaylistItemRow.swift b/Sources/AdaptiveSound/UI/Playlist/PlaylistItemRow.swift index 1c0da06..92b4f9b 100644 --- a/Sources/AdaptiveSound/UI/Playlist/PlaylistItemRow.swift +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistItemRow.swift @@ -1,3 +1,4 @@ +import DesignTokenKit import SwiftUI // MARK: - Playlist Item Row @@ -6,7 +7,13 @@ struct PlaylistItemRow: View { let file: AudioFile let index: Int let isSelected: Bool + /// This row holds the CURRENT track (S10.8 PR D: the realigned tinted card persists + /// while paused — prominence no longer flips off with play state; only the equalizer + /// bars still). let isNowPlaying: Bool + /// Whether playback is actually running — drives the mini equalizer's animation gate + /// (`pulseIsActive`); the card treatment above ignores it. + var isPlaybackActive: Bool = false /// Width of the track-number column, sized by the caller to the widest number in the /// list so 3-/4-digit indices (track 100+) fit on one line instead of wrapping. var numberColumnWidth: CGFloat = 22 @@ -21,6 +28,12 @@ struct PlaylistItemRow: View { /// border so the drop point is visible during the drag (macOS drop-zone affordance). var isDropTarget: Bool = false + /// Row hover reveals the drag grip (S10.8 PR C — realigned: no handles at rest). The + /// grip stays MOUNTED at opacity 0 (hidden, not removed): it keeps its leading slot (no + /// reflow on hover) and stays a live drag source, so reorder works even mid-fade. + @State private var isRowHovered = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + var body: some View { HStack(spacing: 12) { if let dragPayload { @@ -30,6 +43,8 @@ struct PlaylistItemRow: View { // Larger hit area than the thin glyph itself. .frame(width: DesignSystem.QueueRow.gripHitWidth, height: DesignSystem.QueueRow.gripHitHeight) .contentShape(Rectangle()) + .opacity(isRowHovered ? DesignSystem.QueueRow.gripHoverOpacity : 0) + .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: isRowHovered) .draggable(dragPayload) { Text(file.name) .font(DesignSystem.Font.body) @@ -44,14 +59,14 @@ struct PlaylistItemRow: View { .help("Drag to reorder") .accessibilityHidden(true) // the context-menu Move commands are the a11y path } - // Non-color now-playing cue (A-M3): the currently-playing row shows a ▶ glyph in place - // of its number, so "now playing" is not signalled by the row tint alone (colorblind / - // VoiceOver users get no cue from the background opacity otherwise). + // Non-color now-playing cue (A-M3), realigned (PR D): the current row shows the + // 3-bar mini equalizer in place of its number (animating only while playback + // runs and Reduce Motion is off — still bars remain the visible cue), so "now + // playing" is never signalled by the row tint alone. Group { if isNowPlaying { - Image(systemName: "play.fill") - .font(.system(size: 9)) - .foregroundStyle(Color.asAccent) + MiniEqualizer(animating: pulseIsActive(isPlaying: isPlaybackActive, + reduceMotion: reduceMotion)) } else { Text(index + 1, format: .number.grouping(.never)) .font(DesignSystem.Font.monoSmall) @@ -63,8 +78,9 @@ struct PlaylistItemRow: View { VStack(alignment: .leading, spacing: 2) { Text(file.name) - .font(DesignSystem.Font.body.weight(isSelected ? .semibold : .regular)) - .foregroundStyle(isSelected ? Color.asAccent : Color.asLabel) + .font(DesignSystem.Font.body.weight(isSelected || isNowPlaying ? .semibold : .regular)) + .foregroundStyle(isNowPlaying ? DesignSystem.Color.accentTitle + : isSelected ? Color.asAccent : Color.asLabel) .lineLimit(1) Text(file.relativePath) @@ -75,11 +91,11 @@ struct PlaylistItemRow: View { Spacer() - FormatBadgeView(format: file.format, isSelected: isSelected) + FormatBadgeView(format: file.format, isSelected: isSelected || isNowPlaying) Text(file.durationSeconds > 0 ? formatDuration(file.durationSeconds) : "--:--") .font(DesignSystem.Font.monoSmall) - .foregroundStyle(Color.asLabelTertiary) + .foregroundStyle(isNowPlaying ? DesignSystem.Color.accentText : Color.asLabelTertiary) .frame(width: DesignSystem.QueueRow.durationWidth, alignment: .trailing) } // Self-styled row (padding + background) so it renders identically whether it sits in a @@ -89,19 +105,33 @@ struct PlaylistItemRow: View { .padding(.vertical, DesignSystem.Spacing.xSmall) .padding(.horizontal, DesignSystem.Spacing.small) .frame(maxWidth: .infinity, alignment: .leading) - .background(rowTint) + // Realigned card (PR D): the tint is a radius-10 card; the current row adds the + // subtle accent ring (13% fill + 38% ring replaces the old heavy 25% band). + .background(rowTint, in: RoundedRectangle(cornerRadius: DesignSystem.Radius.container, + style: .continuous)) + .overlay { + if isNowPlaying { + RoundedRectangle(cornerRadius: DesignSystem.Radius.container, style: .continuous) + .strokeBorder(DesignSystem.Color.accent.opacity(0.38), lineWidth: 1) + } + } .overlay { if isDropTarget { - Rectangle().strokeBorder(DesignSystem.Color.accent, lineWidth: 2) + RoundedRectangle(cornerRadius: DesignSystem.Radius.container, style: .continuous) + .strokeBorder(DesignSystem.Color.accent, lineWidth: 2) } } .contentShape(Rectangle()) + .onHover { isRowHovered = $0 } + // Full file-path tooltip (deviations §3) — the honest provenance readout on hover; + // `AudioFile.id` IS the absolute URL. + .help(file.id.path) // One VoiceOver element per row (A-M3): a clean label (title · format · duration — NOT the // noisy `relativePath` the auto-composed label pulled in), with now-playing/selected exposed // as a value + trait rather than color alone. `.isButton` is added by the enclosing list. .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) - .accessibilityValue(isNowPlaying ? "Now playing" : "") + .accessibilityValue(isNowPlaying ? (isPlaybackActive ? "Now playing" : "Current track, paused") : "") .accessibilityAddTraits(isSelected ? .isSelected : []) } @@ -122,3 +152,48 @@ struct PlaylistItemRow: View { return parts.joined(separator: ", ") } } + +// MARK: - Mini equalizer (S10.8 PR D — realigned `png/04`) + +/// Three dancing bars in the current row's number slot. Deterministic sine motion (the +/// Realigned Target's spec — supersedes the spectrum-driven plan, recorded in the deviations +/// plan §B); ALL bars rest at `eqBarMinScale` whenever `animating` is false, so pause and +/// Reduce Motion freeze to the same designed still state. +private struct MiniEqualizer: View { + let animating: Bool + + var body: some View { + HStack(alignment: .bottom, spacing: CGFloat(GlassDecor.eqBarSpacing)) { + ForEach(GlassDecor.eqBarDurations.indices, id: \.self) { bar in + EqBar(duration: GlassDecor.eqBarDurations[bar], + phase: GlassDecor.eqBarPhases[bar], + animating: animating) + } + } + .frame(height: CGFloat(GlassDecor.eqBarContainerHeight)) + .accessibilityHidden(true) // the row's a11y value carries the playing state + } +} + +/// One bar: `TimelineView(.animation(paused:))` + sin — pausing the schedule stops the +/// clock AND the ternary pins the still height, so there is no zombie animation to gate +/// (the §3.4 conditional-animator posture in TimelineView form). +private struct EqBar: View { + let duration: Double + let phase: Double + let animating: Bool + + var body: some View { + TimelineView(.animation(paused: !animating)) { context in + let time = context.date.timeIntervalSinceReferenceDate + let minScale = GlassDecor.eqBarMinScale + let scale = animating + ? minScale + (1 - minScale) * (0.5 + 0.5 * sin((time / duration + phase) * 2 * .pi)) + : minScale + RoundedRectangle(cornerRadius: 1) + .fill(DesignSystem.Color.accentBright) + .frame(width: CGFloat(GlassDecor.eqBarWidth)) + .scaleEffect(x: 1, y: scale, anchor: .bottom) + } + } +} diff --git a/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift b/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift index 9c99148..619db72 100644 --- a/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift @@ -14,6 +14,7 @@ import SwiftUI /// than silently failing (§5). struct PlaylistView: View { @Environment(AudioViewModel.self) var viewModel + @Environment(LibraryBrowseModel.self) var library /// 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 @@ -28,37 +29,86 @@ struct PlaylistView: View { /// 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 + /// The header row's Dynamic-Type-scaled minimum height (32pt at default size). + @ScaledMetric(relativeTo: .body) private var headerHeight = DesignSystem.QueueHeader.height + /// The filter pill's scaled height (28pt at default size). + @ScaledMetric(relativeTo: .callout) private var filterHeight = DesignSystem.QueueHeader.filterHeight var body: some View { VStack(spacing: 12) { - PlaylistHeaderView(onJumpToNowPlaying: jumpToNowPlaying, panelMode: $panelMode) - Picker("View", selection: $panelMode) { - ForEach(QueuePanelMode.allCases) { mode in - Text(mode.pickerLabel).tag(mode) - } - } - .pickerStyle(.segmented) - .labelsHidden() + headerRow switch panelMode { case .upNext: if viewModel.queue.isEmpty { emptyQueue + } else if filteredIndices.isEmpty { + noMatches } else { - filterField - if filteredIndices.isEmpty { - noMatches - } else { - PlaylistItemList(jumpToCurrentRequestID: jumpToCurrentRequestID, - visibleIndices: filteredIndices, - reorderEnabled: !filterActive, - queueFocused: $queueFocused) - } + PlaylistItemList(jumpToCurrentRequestID: jumpToCurrentRequestID, + visibleIndices: filteredIndices, + reorderEnabled: !filterActive, + queueFocused: $queueFocused) } case .history: QueueHistoryList() } } + // A filter must not OUTLIVE the queue it narrowed (break-it finding: clear queue → + // pill unmounts with its text retained → the NEXT queue arrives pre-narrowed to a + // possibly-empty match set, with reorder silently disabled). Emptying the queue + // resets the filter; a mode round-trip keeps it (the visible pill carries it). + .onChange(of: viewModel.queue.isEmpty) { _, isEmpty in + if isEmpty { filterText = "" } + } + } + + // MARK: Header (S10.8 PR C — the realigned SINGLE 32pt row, `png/03`) + + /// Title + count + icon chips + the Up Next/Recent capsule pair + the compact filter + /// pill, replacing the stacked header block / segmented picker / full-width filter bar. + /// Width-deficit policy at the 880pt minimum (break-it finding 2): the title is + /// protected, the switcher/chips are rigid (`fixedSize` — a control label must never + /// truncate), the filter compresses to its minimum first, and the COUNT subtitle is + /// the designated truncation victim. Height is a scaled MINIMUM (finding 3) so larger + /// text sizes grow the row instead of clipping. + private var headerRow: some View { + HStack(spacing: 12) { + Text(panelMode == .history ? "Recently Played" : "Queue") + .font(.system(.body, weight: .heavy)) + .tracking(1) + .textCase(.uppercase) + .foregroundStyle(Color.asLabel) + .lineLimit(1) + .fixedSize() + .layoutPriority(1) + + Text(headerSubtitle) + .font(DesignSystem.Font.monoSmall) + .foregroundStyle(Color.asLabelTertiary) + .lineLimit(1) + + PlaylistControlsView(onJumpToNowPlaying: jumpToNowPlaying, panelMode: $panelMode) + .fixedSize() + + QueueModeSwitcher(panelMode: $panelMode) + .fixedSize() + + Spacer(minLength: DesignSystem.Spacing.small) + + // The filter narrows Up Next only (a Recently-Played filter would be new + // function, out of this styling wave) — hidden with the mode, not disabled. + if panelMode == .upNext, !viewModel.queue.isEmpty { + filterField + } + } + .frame(minHeight: headerHeight) + } + + /// Mode-aware count: the queue's track count, or the number of recently-played tracks. + private var headerSubtitle: String { + let count = panelMode == .history ? library.history.count : viewModel.queue.count + return "\(count) \(count == 1 ? "track" : "tracks")" } /// Jump-to-now-playing IGNORES an active filter (§5) — sequenced, not simultaneous: @@ -127,7 +177,12 @@ struct PlaylistView: View { } } .padding(.horizontal, 10) - .frame(height: 26) + // Realigned (`png/03`): a compact right-aligned pill — 190pt ideal, compressing to + // its minimum before the header row's fixed neighbours would overflow (LAY-01). + .frame(minWidth: DesignSystem.QueueHeader.filterMinWidth, + idealWidth: DesignSystem.QueueHeader.filterIdealWidth, + maxWidth: DesignSystem.QueueHeader.filterIdealWidth) + .frame(height: filterHeight) .glassPanel(.badge, in: Capsule()) .accessibilityLabel("Filter queue") } @@ -159,108 +214,6 @@ struct PlaylistView: View { } } -// MARK: - Queue Header - -private struct PlaylistHeaderView: View { - @Environment(AudioViewModel.self) var viewModel - @Environment(LibraryBrowseModel.self) var library - /// `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. - private var subtitle: String { - switch panelMode { - case .upNext: - let count = viewModel.queue.count - return "\(count) \(count == 1 ? "track" : "tracks")" - case .history: - let count = library.history.count - return "\(count) \(count == 1 ? "track" : "tracks")" - } - } - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(panelMode == .history ? "Recently Played" : "Queue") - .font(.caption.weight(.semibold)) - .tracking(0.5) - .textCase(.uppercase) - .foregroundStyle(Color.asLabelSecond) - - Text(subtitle) - .font(DesignSystem.Font.monoSmall) - .foregroundStyle(Color.asLabelTertiary) - } - - Spacer() - - PlaylistControlsView(onJumpToNowPlaying: onJumpToNowPlaying, panelMode: $panelMode) - } - } -} - -// MARK: - Queue Controls - -private struct PlaylistControlsView: View { - @Environment(AudioViewModel.self) var viewModel - let onJumpToNowPlaying: () -> Void - @Binding var panelMode: QueuePanelMode - - var body: some View { - HStack(spacing: 8) { - // Clear Queue — Up Next only, and only when there's something to clear. Immediate - // (no confirm, founder §3): the queue is cheap to rebuild and History is left intact. - if panelMode == .upNext, !viewModel.queue.isEmpty { - Button("Clear Queue", systemImage: "trash", action: viewModel.clearPlaylist) - .labelStyle(.iconOnly) - .font(.system(size: 14)) - .foregroundStyle(Color.asLabelSecond) - .help("Clear the queue (keeps History)") - } - - // Shuffle toggle - Button( - "Shuffle", - systemImage: viewModel.shuffleEnabled ? "shuffle.circle.fill" : "shuffle.circle", - action: viewModel.toggleShuffle - ) - .labelStyle(.iconOnly) - .font(.system(size: 14)) - .foregroundStyle(viewModel.shuffleEnabled ? Color.asAccent : Color.asLabelSecond) - .accessibilityLabel("Shuffle: \(viewModel.shuffleEnabled ? "on" : "off")") - .help("Shuffle: \(viewModel.shuffleEnabled ? "On" : "Off")") - - // Repeat mode toggle - Button( - "Repeat", - systemImage: viewModel.repeatMode == 2 ? "repeat.1.circle.fill" - : viewModel.repeatMode == 1 ? "repeat.circle.fill" - : "repeat.circle", - action: viewModel.cycleRepeatMode - ) - .labelStyle(.iconOnly) - .font(.system(size: 14)) - .foregroundStyle(viewModel.repeatMode > 0 ? Color.asAccent : Color.asLabelSecond) - .accessibilityLabel("Repeat mode: \(["off", "all", "one"][viewModel.repeatMode])") - .help(["Off", "All", "One"][viewModel.repeatMode]) - - // 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", - action: onJumpToNowPlaying) - .labelStyle(.iconOnly) - .font(.system(size: 14)) - .foregroundStyle(Color.asAccent) - .help("Jump to now playing") - } - } - } -} - // MARK: - Playlist Item List private struct PlaylistItemList: View { @@ -289,6 +242,14 @@ private struct PlaylistItemList: View { /// no drag is in progress. @State private var dropTargetIndex: Int? + /// The keyboard-navigation cursor (a REAL queue index), DISTINCT from the now-playing + /// pointer `viewModel.selectedTrackIndex`. Arrow keys move THIS — never the now-playing + /// pointer — so navigating the queue no longer changes the hero / footer / Now Playing + /// (which all read `selectedTrackIndex`); only Return/click actually plays a row and + /// moves that pointer. Nil = no cursor yet (the first arrow seeds it). Drives the + /// `rowSelected` focus tint; the playing row keeps its own now-playing card independently. + @State private var cursorIndex: Int? + /// 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 { @@ -334,25 +295,19 @@ private struct PlaylistItemList: View { .focusEffectDisabled() .frame(maxHeight: .infinity) // Dismiss any open Info popover when the queue changes (remove / clear / reorder) - // so a stale target can't match — and re-present on — a different row. + // so a stale target can't match — and re-present on — a different row. Also drop + // a now-out-of-range cursor (the queue shrank). .onChange(of: viewModel.queue.map(\.id)) { _, _ in infoTarget = nil + if let cursor = cursorIndex, cursor >= viewModel.queue.count { cursorIndex = nil } } - .onKeyPress(.upArrow) { moveSelection(by: -1) } - .onKeyPress(.downArrow) { moveSelection(by: 1) } - .onKeyPress(.return) { togglePlayIfSelected() } + .onKeyPress(.upArrow) { moveCursor(by: -1, proxy: proxy) } + .onKeyPress(.downArrow) { moveCursor(by: 1, proxy: proxy) } + .onKeyPress(.return) { activateCursor() } // No `.onKeyPress(.space)`: the Controls-menu Space key-equivalent is matched first // (disabled only while a text field is focused), so this handler was dead — Return - // 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 - } + // already covers keyboard play/toggle here (focus-audit nit). + .onKeyPress(.delete) { deleteCursorRow() } // Scroll the current track into view when the header's "Jump to Now Playing" fires (UI-2). // Target the row's stable id (matches `.id(item.id)`), not a positional index. .onChange(of: jumpToCurrentRequestID) { _, _ in @@ -366,8 +321,14 @@ private struct PlaylistItemList: View { PlaylistItemRow( file: item.file, index: index, - isSelected: viewModel.selectedTrackIndex == index, - isNowPlaying: viewModel.isPlaying && viewModel.selectedTrackIndex == index, + // `isSelected` follows the KEYBOARD CURSOR (the focus tint), not the now-playing + // pointer — so arrowing the queue highlights the focused row without disturbing + // the hero. The now-playing card is a separate cue below. + isSelected: cursorIndex == index, + // PR D: the CURRENT row keeps its card while paused (prominence is no longer + // tied to play state); `isPlaybackActive` gates only the equalizer motion. + isNowPlaying: viewModel.selectedTrackIndex == index, + isPlaybackActive: viewModel.isPlaying, numberColumnWidth: numberColumnWidth, // 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 @@ -395,8 +356,10 @@ private struct PlaylistItemList: View { } .simultaneousGesture( TapGesture().onEnded { - // A click on a row focuses the queue for the keyboard shortcuts. + // A click on a row focuses the queue for the keyboard shortcuts and lands the + // cursor here (so subsequent arrows continue from where you clicked). queueFocused.wrappedValue = true + cursorIndex = index // 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 } @@ -449,21 +412,53 @@ private struct PlaylistItemList: View { } } - /// Move the selection by `delta` rows, clamped to the queue (keyboard ↑/↓). `.ignored` when - /// there's no selection or the move would leave the queue, so the event can bubble. - private func moveSelection(by delta: Int) -> KeyPress.Result { - guard let current = viewModel.selectedTrackIndex else { return .ignored } - let next = current + delta - guard next >= 0, next < viewModel.queue.count else { return .ignored } - viewModel.selectedTrackIndex = next + /// Move the keyboard CURSOR by `delta` VISIBLE rows — never `selectedTrackIndex`, so the + /// hero/footer/Now Playing (which read that pointer) don't move while you navigate + /// (founder bug). Navigates the visible (filter-narrowed) set, so the cursor can't land + /// on a hidden row. Seeds onto the playing row when it's visible, else the first/last + /// visible row, on the first press; scrolls the cursor into view. `.ignored` when the + /// move would leave the list, so the event can bubble. + private func moveCursor(by delta: Int, proxy: ScrollViewProxy) -> KeyPress.Result { + guard !visibleIndices.isEmpty else { return .ignored } + let anchor: Int + if let cursor = cursorIndex, let pos = visibleIndices.firstIndex(of: cursor) { + anchor = pos + } else if let playing = viewModel.selectedTrackIndex, + let pos = visibleIndices.firstIndex(of: playing) { + anchor = pos + } else { + // No cursor and the playing row isn't visible: the first ↓ lands on row 0, ↑ on + // the last row (a virtual anchor just off each end). + anchor = delta > 0 ? -1 : visibleIndices.count + } + let nextPos = anchor + delta + guard nextPos >= 0, nextPos < visibleIndices.count else { return .ignored } + let target = visibleIndices[nextPos] + cursorIndex = target + // Keep the cursor on screen (nil anchor = scroll the minimum needed, no jump). + proxy.scrollTo(viewModel.queue[target].id) + return .handled + } + + /// Return: play the cursor row, or toggle play/pause when the cursor is already the + /// playing track (mirrors the row's tap semantics). `.ignored` with no cursor so the key + /// can bubble. + private func activateCursor() -> KeyPress.Result { + guard let index = cursorIndex, index < viewModel.queue.count else { return .ignored } + if viewModel.selectedTrackIndex == index { + viewModel.togglePlayPause() + } else { + viewModel.playTrack(at: index) + } return .handled } - /// Toggle play/pause for the selected row (keyboard Return/Space). `.ignored` when nothing is - /// selected so the key can do its default thing. - private func togglePlayIfSelected() -> KeyPress.Result { - guard viewModel.selectedTrackIndex != nil else { return .ignored } - viewModel.togglePlayPause() + /// Delete: remove the cursor row — visible-only, so a filter-hidden row (possibly the + /// playing track) can't be removed with no visible target (break-it MINOR-2). The next + /// row slides under the cursor position. + private func deleteCursorRow() -> KeyPress.Result { + guard let index = cursorIndex, visibleIndices.contains(index) else { return .ignored } + viewModel.removeTrack(at: index) return .handled } } diff --git a/Sources/AdaptiveSound/UI/Playlist/QueueHeaderControls.swift b/Sources/AdaptiveSound/UI/Playlist/QueueHeaderControls.swift new file mode 100644 index 0000000..b2e5d4c --- /dev/null +++ b/Sources/AdaptiveSound/UI/Playlist/QueueHeaderControls.swift @@ -0,0 +1,133 @@ +import SwiftUI + +// MARK: - Queue Controls (S10.8 PR C — 28pt icon chips, realigned `png/03`) + +struct PlaylistControlsView: View { + @Environment(AudioViewModel.self) var viewModel + let onJumpToNowPlaying: () -> Void + @Binding var panelMode: QueuePanelMode + + var body: some View { + HStack(spacing: 4) { + // Clear Queue — Up Next only, and only when there's something to clear. Immediate + // (no confirm, founder §3): the queue is cheap to rebuild and History is left intact. + if panelMode == .upNext, !viewModel.queue.isEmpty { + QueueIconButton(title: "Clear Queue", systemImage: "trash", + action: viewModel.clearPlaylist) + .help("Clear the queue (keeps History)") + } + + QueueIconButton(title: "Shuffle", systemImage: "shuffle", + isOn: viewModel.shuffleEnabled, + action: viewModel.toggleShuffle) + .accessibilityLabel("Shuffle: \(viewModel.shuffleEnabled ? "on" : "off")") + .help("Shuffle: \(viewModel.shuffleEnabled ? "On" : "Off")") + + QueueIconButton(title: "Repeat", + systemImage: viewModel.repeatMode == 2 ? "repeat.1" : "repeat", + isOn: viewModel.repeatMode > 0, + action: viewModel.cycleRepeatMode) + .accessibilityLabel("Repeat mode: \(["off", "all", "one"][viewModel.repeatMode])") + .help(["Off", "All", "One"][viewModel.repeatMode]) + + // 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 { + QueueIconButton(title: "Jump to Now Playing", systemImage: "play.circle", + accented: true, + action: onJumpToNowPlaying) + .help("Jump to now playing") + } + } + } +} + +/// One 28×28 header chip: resting badge wash → hover lift → toggled-on accent tint with a +/// ring and an `accentText` glyph (all token'd, audited by R4-CHIP-01). The chip is built +/// INSIDE the button's label (frame/background/contentShape wrapped around a Button never +/// extend its hit region — break-it finding 1), and its height scales with Dynamic Type +/// like the chrome tab strip (finding 3). +struct QueueIconButton: View { + let title: String + let systemImage: String + var isOn: Bool = false + /// An always-teal ACTION glyph (jump-to-now-playing) — accent identity without the + /// toggled-on chip treatment. + var accented: Bool = false + let action: () -> Void + + @State private var hovering = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @ScaledMetric(relativeTo: .callout) private var chipSide = DesignSystem.QueueHeader.iconButton + + var body: some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .labelStyle(.iconOnly) + .font(.system(size: DesignSystem.QueueHeader.iconSymbol, weight: .medium)) + .foregroundStyle(isOn || accented ? DesignSystem.Color.accentText : Color.asLabelSecond) + .frame(width: chipSide, height: chipSide) + .background { + RoundedRectangle(cornerRadius: DesignSystem.Radius.control, style: .continuous) + .fill(isOn ? DesignSystem.Color.controlActiveFill + : hovering ? DesignSystem.Color.controlHover : DesignSystem.Color.hoverWash) + } + .overlay { + if isOn { + RoundedRectangle(cornerRadius: DesignSystem.Radius.control, style: .continuous) + .strokeBorder(DesignSystem.Color.accent.opacity(0.3), lineWidth: 1) + } + } + .contentShape(RoundedRectangle(cornerRadius: DesignSystem.Radius.control, + style: .continuous)) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .animation(reduceMotion ? nil : .easeOut(duration: 0.12), value: hovering) + } +} + +// MARK: - Queue mode switcher (Up Next / Recent — the mini capsule pair) + +/// The realigned segmented pair: a small `tabTrack` capsule with a `segmentSelected` lift — +/// the tab strip's grammar at header scale. Replaces `.pickerStyle(.segmented)`. The +/// segment height is Dynamic-Type-scaled; the header row `fixedSize()`s this control so +/// its labels can never be the header's truncation victim. +struct QueueModeSwitcher: View { + @Binding var panelMode: QueuePanelMode + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @ScaledMetric(relativeTo: .callout) + private var segmentHeight = DesignSystem.QueueHeader.segmentHeight + + var body: some View { + HStack(spacing: 2) { + ForEach(QueuePanelMode.allCases) { mode in + let selected = mode == panelMode + Button { + panelMode = mode + } label: { + Text(mode.pickerLabel) + .font(.callout.weight(selected ? .bold : .semibold)) + .foregroundStyle(selected ? Color.asLabel : Color.asLabelSecond) + .lineLimit(1) + .padding(.horizontal, 10) + .frame(height: segmentHeight) + .background { + if selected { + Capsule().fill(DesignSystem.Color.segmentSelected) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selected ? [.isSelected] : []) + } + } + .padding(DesignSystem.QueueHeader.segmentPadding) + .background(TabTrackCapsule()) + .animation(reduceMotion ? nil : .snappy(duration: 0.18), value: panelMode) + .accessibilityElement(children: .contain) + .accessibilityLabel("Queue view") + .accessibilityValue(panelMode.pickerLabel) + } +} diff --git a/Sources/AdaptiveSound/UI/Shell/AppShell.swift b/Sources/AdaptiveSound/UI/Shell/AppShell.swift index 4765754..79ef91a 100644 --- a/Sources/AdaptiveSound/UI/Shell/AppShell.swift +++ b/Sources/AdaptiveSound/UI/Shell/AppShell.swift @@ -28,8 +28,9 @@ struct AppShell: View { header .frame(height: DesignSystem.ShellMetrics.chromeHeight) .frame(maxWidth: .infinity) - .background(DesignSystem.Color.window) - .overlay(alignment: .bottom) { Hairline() } + // Realigned styled glass (S10.8 PR G): fill strata + specular/seam edges — + // owns what `.background(window)` + the bottom `Hairline` did. + .chromeBand(lightFrom: .top) // `maxHeight: .infinity` claims the space between the bands; `.top` alignment forces // an over-tall child to spill DOWNWARD (never up into the chrome); `.clipped()` @@ -43,8 +44,9 @@ struct AppShell: View { footer .frame(height: DesignSystem.ShellMetrics.footerHeight) .frame(maxWidth: .infinity) - .background(DesignSystem.Color.panel) - .overlay(alignment: .top) { Hairline() } + // Realigned styled glass, light source flipped (lit from its bottom); the + // specular top edge replaces the old top `Hairline` + `panel` fill. + .chromeBand(lightFrom: .bottom) } .background(DesignSystem.Color.window) .frame( diff --git a/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift b/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift index cf7ffcc..65b9b19 100644 --- a/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift +++ b/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift @@ -27,9 +27,11 @@ struct ChromeBar: View { DevicePillView() - TabSelectorView(selectedTab: $selectedTab, reduceMotion: reduceMotion) - Spacer(minLength: 8) + + // Realigned (`png/00`/`png/01`, founder round 1): the tab strip sits on the + // chrome's RIGHT edge — logo + device pill left, spacer between. + TabSelectorView(selectedTab: $selectedTab, reduceMotion: reduceMotion) } // Shares the content's leading margin: with the native titlebar restored, the window // buttons live in their own strip, so the chrome no longer insets to clear them — its @@ -48,11 +50,13 @@ struct ChromeBar: View { private struct AppLogoView: View { var body: some View { ZStack { - RoundedRectangle(cornerRadius: 8, style: .continuous) + // Radius 9 squircle + the waveform brand mark (deviations §1 — the 8a mark is + // the 5-bar waveform, not a note glyph). + RoundedRectangle(cornerRadius: 9, style: .continuous) .fill(LinearGradient.asIconFill) .frame(width: 30, height: 30) - Image(systemName: "music.note") + Image(systemName: "waveform") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(DesignSystem.Color.onAccent) } @@ -95,6 +99,11 @@ private struct DevicePillView: View { Text(viewModel.selectedDevice?.name ?? "No Device") .lineLimit(1) .truncationMode(.tail) + // Realigned (png/01): an explicit dropdown chevron between the name and + // the rate readout — the borderless Menu label shows no indicator of its own. + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(Color.asLabelTertiary) } .font(.callout.weight(.medium)) .foregroundStyle(Color.asLabel) @@ -127,7 +136,11 @@ private struct DevicePillView: View { // 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) + // 302 (was 288, before that 252): each fixed sibling added to the pill squeezes the + // NAME first — the PR-6 rate slot truncated "MacBook Pr…" (deviations audit), then + // the PR-B dropdown chevron (~14pt with spacing) re-truncated "MacBook Pro Spe…" + // (founder round-1 screenshot). The width absorbs the chevron exactly. + .frame(minWidth: 302, maxWidth: 302, 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()) @@ -142,26 +155,59 @@ private struct DevicePillView: View { // MARK: - Tab Selector +/// The realigned capsule tab strip (S10.8 PR B, D9 reopened — founder 2026-07-19): a carved +/// dark track holding one teal-gradient capsule for the active tab; inactive tabs lighten on +/// hover. Replaces `.pickerStyle(.segmented)`. Each tab is a real `Button` (keyboard Tab + +/// Space/Return activation — the picker's arrow-key model is traded for standard button +/// focus, with the selection exposed via the `.isSelected` trait). Selection movement is +/// animated (`.snappy`), Reduce-Motion gated like the picker's old easing. private struct TabSelectorView: View { @Binding var selectedTab: TabSelection let reduceMotion: Bool + @State private var hoveredTab: TabSelection? + /// Realigned: 28pt capsule in a 34pt track — scaled with Dynamic Type (the chrome band + /// clamps at .xLarge) so the strip never clips its labels. + @ScaledMetric(relativeTo: .callout) + private var capsuleHeight = CGFloat(GlassDecor.tabCapsuleBaseHeight) + var body: some View { - Picker( - selection: $selectedTab.animation(reduceMotion ? nil : .easeInOut(duration: 0.2)) - ) { + HStack(spacing: CGFloat(GlassDecor.tabSpacing)) { ForEach(TabSelection.allCases, id: \.id) { tab in - Label(tab.rawValue, systemImage: tab.icon) - .tag(tab) + tabButton(tab) } - } label: { - EmptyView() // no picker label at all — the VoiceOver name comes from .accessibilityLabel below } - .pickerStyle(.segmented) - // Lock the segmented control to its intrinsic size so the tabs never stretch with the - // window or compress — a stable, fixed-size chrome control (layoutPriority is now moot). - .fixedSize() + .padding(CGFloat(GlassDecor.tabTrackPadding)) + .background(TabTrackCapsule()) + .animation(reduceMotion ? nil : .snappy(duration: 0.18), value: selectedTab) + .accessibilityElement(children: .contain) .accessibilityLabel("Tab Navigation") .accessibilityValue(selectedTab.rawValue) } + + private func tabButton(_ tab: TabSelection) -> some View { + let active = tab == selectedTab + return Button { + selectedTab = tab + } label: { + Text(tab.rawValue) + .font(.callout.weight(active ? .bold : .semibold)) + .foregroundStyle(active ? DesignSystem.Color.onAccent + : hoveredTab == tab ? DesignSystem.Color.label : DesignSystem.Color.labelSecondary) + .lineLimit(1) + .padding(.horizontal, 15) + .frame(height: capsuleHeight) + .background { + if active { + ActiveTabCapsule() + } else if hoveredTab == tab { + Capsule().fill(DesignSystem.Color.hoverWash) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { hoveredTab = $0 ? tab : nil } + .accessibilityAddTraits(active ? [.isSelected] : []) + } } diff --git a/Sources/AdaptiveSound/UI/Shell/Hairline.swift b/Sources/AdaptiveSound/UI/Shell/Hairline.swift deleted file mode 100644 index a7713a2..0000000 --- a/Sources/AdaptiveSound/UI/Shell/Hairline.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SwiftUI - -/// A 0.5pt hairline separator. -/// -/// A *filled* `Rectangle` (not `Divider`) painted in `DesignSystem.Color.hairline`: -/// `Divider` ignores `foregroundStyle` on macOS, so the shell uses this wherever a -/// header / footer / card edge needs a crisp, appearance-reactive rule. -struct Hairline: View { - var body: some View { - Rectangle() - .fill(DesignSystem.Color.hairline) - .frame(height: DesignSystem.ShellMetrics.hairline) - } -} diff --git a/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift b/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift index 0368db9..5fd4c93 100644 --- a/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift +++ b/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift @@ -1,3 +1,4 @@ +import DesignTokenKit import SwiftUI // MARK: - Now Playing Bar (footer transport — L3) @@ -171,8 +172,9 @@ private struct FooterTransportControls: View { .accessibilityLabel(label) } - /// Play/Pause — the app's signature gradient circle when loaded; a flat, disabled card - /// circle when idle (same 34pt frame, so no reflow between states). + /// Play/Pause — the glossy teal circle when loaded (S10.8 PR G: the shared `TealGloss` + /// — inner top highlight + dark-only teal glow, same recipe as the active tab); a flat, + /// disabled card circle when idle (same 34pt frame, so no reflow between states). private var playButton: some View { Button { viewModel.togglePlayPause() @@ -183,12 +185,11 @@ private struct FooterTransportControls: View { .frame(width: DesignSystem.Footer.playButton, height: DesignSystem.Footer.playButton) .background { if isLoaded { - Circle().fill(DesignSystem.Gradient.iconFill) + TealGloss(shape: Circle()) } else { Circle().fill(DesignSystem.Color.card) } } - .clipShape(Circle()) .contentShape(Circle()) } .buttonStyle(FooterControlButtonStyle()) @@ -264,14 +265,15 @@ private struct FooterScrubber: View { let trackWidth = geo.size.width ZStack(alignment: .leading) { // 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). + // Fill follows playback; while playing it carries the realigned teal + // gradient (S10.8 PR E — shared with sliders/meters), and the dark-only + // glow shows only then (paused = dim teal, interrupted = grey). The state + // change swaps erased style TYPES (gradient ↔ solid), which SwiftUI cannot + // interpolate — the old play↔pause ease is gone by construction, so no + // animation modifier here (a hard swap either way). CarvedGroove(fillFraction: fraction, - fillColor: fillColor, + fillStyle: fillStyle, 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). if viewModel.duration > 0, isHovered || isDragging { @@ -339,9 +341,11 @@ private struct FooterScrubber: View { .accessibilityHidden(true) } - private var fillColor: Color { - if isInterrupted { return DesignSystem.Color.labelTertiary } - return viewModel.isPlaying ? DesignSystem.Color.accent : DesignSystem.Color.accent.opacity(0.5) + private var fillStyle: AnyShapeStyle { + if isInterrupted { return AnyShapeStyle(DesignSystem.Color.labelTertiary) } + return viewModel.isPlaying + ? AnyShapeStyle(DesignSystem.Gradient.meterFill) + : AnyShapeStyle(DesignSystem.Color.accent.opacity(0.5)) } private var accessibilityValue: String { @@ -356,6 +360,7 @@ private struct FooterScrubber: View { private struct FooterSignalSlot: View { @Environment(AudioViewModel.self) private var viewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion let isLoaded: Bool var body: some View { @@ -381,21 +386,45 @@ private struct FooterSignalSlot: View { .foregroundStyle(DesignSystem.Color.labelSecondary) .lineLimit(1) } else { - Circle() - .fill(dotColor(info)) - .frame(width: 6, height: 6) - Text("\(info.path == .pure ? "Pure" : "Enhanced") · \(info.formattedRate)") + // Realigned (S10.8 PR G, `png/06`): the Enhanced dot is the hero chip's teal, + // pulsing with the same §3.4 gate (playing + Reduce Motion off); "Enhanced" + // reads in accentText. Pure/fallback keep their established dot colors. + statusDot(info) + // Teal "Enhanced" ONLY for the clean enhanced state: under Pure-fallback the + // dot is the amber warning, and brand-teal text beside it would read as two + // different states in 12pt of space (break-it finding) — neutral label there. + (Text(info.path == .pure ? "Pure" : "Enhanced") + .foregroundStyle(info.path == .enhanced && !info.fellBackToEnhanced + ? DesignSystem.Color.accentText : DesignSystem.Color.labelSecondary) + + Text(" · \(info.formattedRate)") + .foregroundStyle(DesignSystem.Color.labelSecondary)) .font(DesignSystem.Font.monoSmall) - .foregroundStyle(DesignSystem.Color.labelSecondary) .lineLimit(1) .truncationMode(.tail) } } + @ViewBuilder private func statusDot(_ info: SignalPathInfo) -> some View { + let dot = Circle() + .fill(dotColor(info)) + .frame(width: 6, height: 6) + if info.path == .enhanced, !info.fellBackToEnhanced, + pulseIsActive(isPlaying: viewModel.isPlaying, reduceMotion: reduceMotion) { + // Conditional phaseAnimator (§3.4) — unmounting IS the deterministic stop. + dot.phaseAnimator([1.0, GlassDecor.pulseDimOpacity]) { view, opacity in + view.opacity(opacity) + } animation: { _ in + .easeInOut(duration: GlassDecor.pulseHalfCycleSeconds) + } + } else { + dot + } + } + private func dotColor(_ info: SignalPathInfo) -> Color { if info.fellBackToEnhanced || info.interrupted { return DesignSystem.Color.statusWarning } if info.path == .pure { return DesignSystem.Color.accent } - return DesignSystem.Color.labelTertiary + return DesignSystem.Color.accentBright } private var accessibilityValue: String { diff --git a/Sources/AudioDSP/Limiting/LimiterModule.h b/Sources/AudioDSP/Limiting/LimiterModule.h index 30e0fd7..ae2ebdc 100644 --- a/Sources/AudioDSP/Limiting/LimiterModule.h +++ b/Sources/AudioDSP/Limiting/LimiterModule.h @@ -68,6 +68,7 @@ #include "../include/AudioConstants.h" #include "../include/MultichannelView.h" #include "../include/TargetState.h" +#include "../include/TruePeakKernel.h" #include #include #include @@ -75,20 +76,15 @@ #include #include #include -#include #include namespace AdaptiveSound { // --- Polyphase ISP detector (replaces M1 4× linear interpolation) ----------- - inline constexpr uint32_t kIspOversampling = 8U; // 8× polyphase upsample - inline constexpr uint32_t kIspNumTaps = 24U; // taps per phase (windowed sinc) - inline constexpr uint32_t kIspPrototypeN = kIspOversampling * kIspNumTaps; // 192 - inline constexpr double kIspKaiserBeta = 8.0; // Kaiser β (≈ −98 dB stopband) - inline constexpr double kIspProtoCutoffNorm = 0.0625; // 0.5/L: pass base band, reject images - inline constexpr uint32_t kI0MaxTerms = 25U; // I0 Bessel series term cap - inline constexpr double kI0ConvergeEps = 1.0e-16; // I0 series termination + // The 8×24 windowed-sinc kernel itself lives in include/TruePeakKernel.h (S10.8 PR E + // extraction) — shared with the loudness meter's true-peak readout so both run the + // SAME oracle-verified design. Only the limiter-specific margin stays here. // Working-ceiling margin: polyphase worst-case under-read < 0.17 dB + 0.10 guard // → −0.27 dB = 10^(−0.27/20). (Was −0.5 dB / 0.94406 under M1 linear-interp.) @@ -291,82 +287,29 @@ namespace AdaptiveSound return 1.0 - std::exp(-1.0 / (tauSeconds * static_cast(sampleRate))); } - // Modified Bessel I0(x) via series Σ ((x/2)^k / k!)² (libc++ lacks - // std::cyl_bessel_i). Pure, off-RT. - [[nodiscard]] static auto kaiserI0(double xValue) noexcept -> double - { - const double half = xValue / 2.0; - double term = 1.0; - double sum = 1.0; - for (uint32_t k = 1U; k <= kI0MaxTerms; ++k) - { - const double ratio = half / static_cast(k); - term *= ratio * ratio; - sum += term; - if (term < sum * kI0ConvergeEps) - { - break; - } - } - return sum; - } - - // Build the 8×24 windowed-sinc polyphase upsampler into ispCoeffs_ (flat, - // phase-major: ispCoeffs_[phase*kIspNumTaps + tap] = h[phase + tap*L]). - // h[n] = L·(2·fc)·sinc(2·fc·(n−M))·kaiser(n,β), M = (N−1)/2. Off-RT. + // Build the 8×24 windowed-sinc polyphase upsampler into ispCoeffs_. The kernel + // math (incl. the Bessel-I0 Kaiser window) lives in TruePeakKernel (S10.8 PR E + // extraction — shared with the loudness meter's true-peak readout). Off-RT. void computePolyphaseCoeffs() noexcept { - const double center = static_cast(kIspPrototypeN - 1U) / 2.0; // 95.5 - const double twoFc = 2.0 * kIspProtoCutoffNorm; // 0.125 = 1/L - const double scale = static_cast(kIspOversampling) * twoFc; // = 1.0 - const double i0Beta = kaiserI0(kIspKaiserBeta); - const double denom = static_cast(kIspPrototypeN - 1U); - - for (uint32_t i = 0U; i < kIspPrototypeN; ++i) - { - // dist is half-integer (center = 95.5) → sincArg is never exactly 0. - const double dist = static_cast(i) - center; - const double sincArg = twoFc * dist; - const double sincVal = - std::sin(std::numbers::pi * sincArg) / (std::numbers::pi * sincArg); - const double ratio = (2.0 * dist) / denom; - const double winArg = - kIspKaiserBeta * std::sqrt(std::max(0.0, 1.0 - (ratio * ratio))); - const double window = kaiserI0(winArg) / i0Beta; - - const uint32_t phase = i % kIspOversampling; - const uint32_t tap = i / kIspOversampling; - ispCoeffs_[(static_cast(phase) * kIspNumTaps) + tap] = - scale * sincVal * window; - } + TruePeakKernel::computeCoefficients(ispCoeffs_); } // 8× polyphase inter-sample true-peak of the sample just written at // `writePos`, for ONE channel. Reads that channel's 24-sample ring history - // (handles wrap using ringSize_), runs 8 dot-products, returns max |·| (double). - // The caller maxes this over all active channels to drive the linked gain. + // (handles wrap using ringSize_) into the kernel's newest-first window, then + // runs the shared phase dot-products. The caller maxes this over all active + // channels to drive the linked gain. [[nodiscard]] auto polyphaseIspPeak(uint32_t channel, uint32_t writePos) const noexcept -> double { - std::array hist{}; - for (uint32_t k = 0U; k < kIspNumTaps; ++k) + TruePeakKernel::History hist{}; + for (uint32_t k = 0U; k < TruePeakKernel::kNumTaps; ++k) { const uint32_t idx = (writePos + ringSize_ - k) % ringSize_; hist[k] = static_cast(rings_[channel][idx]); } - - double maxPeak = 0.0; - for (uint32_t phase = 0U; phase < kIspOversampling; ++phase) - { - const size_t base = static_cast(phase) * kIspNumTaps; - double dot = 0.0; - for (uint32_t k = 0U; k < kIspNumTaps; ++k) - { - dot += ispCoeffs_[base + k] * hist[k]; - } - maxPeak = std::max(maxPeak, std::abs(dot)); - } - return maxPeak; + return TruePeakKernel::phasePeak(hist, ispCoeffs_); } // Required gain reduction (dB, ≤ 0) to bring `peak` down to the working @@ -479,7 +422,7 @@ namespace AdaptiveSound // ----------------------------------------------------------------------- // Polyphase coefficient table (flat phase-major; computed off-RT). - std::array(kIspOversampling) * kIspNumTaps> ispCoeffs_{}; + TruePeakKernel::Coefficients ispCoeffs_{}; // Look-ahead ring buffers: one std::vector per channel, each sized to // ringSize_ = kLimiterLookaheadFrames + maxFrames_ in initialize(). Heap-allocated diff --git a/Sources/AudioDSP/Loudness/LoudnessMeterBridge.cpp b/Sources/AudioDSP/Loudness/LoudnessMeterBridge.cpp index 45c7c9c..3763c25 100644 --- a/Sources/AudioDSP/Loudness/LoudnessMeterBridge.cpp +++ b/Sources/AudioDSP/Loudness/LoudnessMeterBridge.cpp @@ -9,19 +9,23 @@ // off-RT (engine init/teardown). #include "../include/DeviceBridge.h" +#include "../include/TruePeakKernel.h" #include "LufsMeter.h" #include +#include #include #include +#include #include namespace { constexpr float kPeakDecayPerBuffer = 0.85F; // ~150 ms visual decay at tap rate constexpr double kPeakFloorDb = -120.0; - constexpr float kPeakFloorLinear = 1.0e-6F; + constexpr double kPeakFloorLinear = 1.0e-6; constexpr double kDbAmplitudeScale = 20.0; constexpr double kUnmeasuredLufs = -200.0; + constexpr uint32_t kTruePeakChannels = 2U; struct LoudnessMeterHandle { @@ -29,8 +33,15 @@ namespace std::atomic integrated{kUnmeasuredLufs}; std::atomic shortTerm{kUnmeasuredLufs}; std::atomic momentary{kUnmeasuredLufs}; - std::atomic peakDb{kPeakFloorDb}; - float peakLinear = 0.0F; // tap-thread only + std::atomic truePeakDb{kPeakFloorDb}; + + // True-peak state (S10.8 PR E — the meter path was SAMPLE-peak before; the UI's + // "True peak" label is honest only because this is the same 8× polyphase ISP + // kernel the limiter's ceiling runs). Tap-thread only. + AdaptiveSound::TruePeakKernel::Coefficients ispCoeffs{}; + std::array, kTruePeakChannels> + histories{}; // newest-first windows, shifted per sample + double truePeakLinear = 0.0; }; static_assert(std::atomic::is_always_lock_free, @@ -43,6 +54,7 @@ void* loudnessMeterCreate(double sampleRate) AUDIODSP_C_NOEXCEPT if (handle != nullptr) { handle->meter.prepare(sampleRate); + AdaptiveSound::TruePeakKernel::computeCoefficients(handle->ispCoeffs); } return handle; } @@ -63,20 +75,38 @@ void loudnessMeterAddStereo(void* meter, const float* left, const float* right, const float* rightChannel = (right != nullptr) ? right : left; handle->meter.addNonInterleavedStereo(left, rightChannel, frames); - // Sample-peak with per-buffer decay (the true-peak limiter is not in this path). - float peak = handle->peakLinear * kPeakDecayPerBuffer; + // Inter-sample TRUE peak (8× polyphase ISP — the shared TruePeakKernel), with the + // same per-buffer visual decay the old sample-peak readout used. Histories are + // newest-first shift registers (24 doubles/channel; the shift is cheaper than the + // 8×24 dot products that follow it). Mono runs ONE channel — the aliased right would + // reproduce channel 0's dot products exactly (break-it finding 7). + using AdaptiveSound::TruePeakKernel::kNumTaps; + const uint32_t activeChannels = (right != nullptr) ? kTruePeakChannels : 1U; + double peak = handle->truePeakLinear * kPeakDecayPerBuffer; for (uint32_t i = 0U; i < frames; ++i) { - peak = std::max({peak, std::abs(left[i]), std::abs(rightChannel[i])}); + const std::array samples{static_cast(left[i]), + static_cast(rightChannel[i])}; + for (uint32_t ch = 0U; ch < activeChannels; ++ch) + { + auto& hist = handle->histories[ch]; + for (uint32_t k = kNumTaps - 1U; k > 0U; --k) + { + hist[k] = hist[k - 1U]; + } + hist[0] = samples[ch]; + peak = + std::max(peak, AdaptiveSound::TruePeakKernel::phasePeak(hist, handle->ispCoeffs)); + } } - handle->peakLinear = peak; - const double peakDecibels = + handle->truePeakLinear = peak; + const double truePeakDecibels = (peak > kPeakFloorLinear) ? (kDbAmplitudeScale * std::log10(peak)) : kPeakFloorDb; handle->integrated.store(handle->meter.integratedLufs(), std::memory_order_release); handle->shortTerm.store(handle->meter.shortTermLufs(), std::memory_order_release); handle->momentary.store(handle->meter.momentaryLufs(), std::memory_order_release); - handle->peakDb.store(peakDecibels, std::memory_order_release); + handle->truePeakDb.store(truePeakDecibels, std::memory_order_release); } CLoudnessReadout loudnessMeterRead(void* meter) AUDIODSP_C_NOEXCEPT @@ -90,6 +120,6 @@ CLoudnessReadout loudnessMeterRead(void* meter) AUDIODSP_C_NOEXCEPT out.integratedLufs = handle->integrated.load(std::memory_order_acquire); out.shortTermLufs = handle->shortTerm.load(std::memory_order_acquire); out.momentaryLufs = handle->momentary.load(std::memory_order_acquire); - out.peakDb = handle->peakDb.load(std::memory_order_acquire); + out.truePeakDb = handle->truePeakDb.load(std::memory_order_acquire); return out; } diff --git a/Sources/AudioDSP/include/DeviceBridge.h b/Sources/AudioDSP/include/DeviceBridge.h index 364de85..16bd34b 100644 --- a/Sources/AudioDSP/include/DeviceBridge.h +++ b/Sources/AudioDSP/include/DeviceBridge.h @@ -57,13 +57,14 @@ typedef struct // MARK: - CLoudnessReadout /// Flat C struct: the latest BS.1770-5 loudness readout for the UI meters. -/// Values are LUFS except peakDb (sample-peak dBFS). Unmeasured = very negative. +/// Values are LUFS except truePeakDb — inter-sample TRUE peak in dBTP (8× polyphase ISP, +/// the shared TruePeakKernel; was sample-peak before S10.8 PR E). Unmeasured = very negative. typedef struct { double integratedLufs; double shortTermLufs; double momentaryLufs; - double peakDb; + double truePeakDb; } CLoudnessReadout; // MARK: - C-ABI device functions diff --git a/Sources/AudioDSP/include/TruePeakKernel.h b/Sources/AudioDSP/include/TruePeakKernel.h new file mode 100644 index 0000000..b09a186 --- /dev/null +++ b/Sources/AudioDSP/include/TruePeakKernel.h @@ -0,0 +1,105 @@ +#pragma once + +// TruePeakKernel — the shared 8× polyphase windowed-sinc inter-sample-peak kernel +// (ITU-R BS.1770-5 Annex 2 posture; Kaiser β=8 ≈ −98 dB stopband). +// +// EXTRACTED from LimiterModule (S10.8 PR E) so the limiter's true-peak ceiling detector +// and the loudness meter's true-peak readout share ONE verified design: the libebur128 +// conformance oracle (Tests/LoudnessOracleTests.inc) exercises this kernel through the +// limiter, and Loudness_TruePeakKernel_InterSample drives it directly. +// +// Pure, header-only, allocation-free: coefficient generation is off-RT; `phasePeak` is +// the per-sample hot path (8 × 24 dot products) both the limiter's render loop and the +// meter's tap thread run. +// +// References: ITU-R BS.1770-5 Annex 2; jiixyj/libebur128, x42/dpl.lv2 (oversampled +// true-peak detection); Kaiser & Schafer (I0 window). + +#include +#include +#include +#include +#include +#include + +namespace AdaptiveSound::TruePeakKernel +{ + inline constexpr uint32_t kOversampling = 8U; // 8× polyphase upsample + inline constexpr uint32_t kNumTaps = 24U; // taps per phase (windowed sinc) + inline constexpr uint32_t kPrototypeN = kOversampling * kNumTaps; // 192 + inline constexpr double kKaiserBeta = 8.0; // Kaiser β (≈ −98 dB stopband) + inline constexpr double kProtoCutoffNorm = 0.0625; // 0.5/L: pass base band, reject images + inline constexpr uint32_t kI0MaxTerms = 25U; // I0 Bessel series term cap + inline constexpr double kI0ConvergeEps = 1.0e-16; // I0 series termination + + /// Flat phase-major coefficient bank: coeffs[phase*kNumTaps + tap] = h[phase + tap*L]. + using Coefficients = std::array; + /// Newest-first sample history window for one channel. + using History = std::array; + + /// Zeroth-order modified Bessel I0 (Kaiser window normalizer), series form. + [[nodiscard]] inline auto kaiserI0(double xValue) noexcept -> double + { + const double half = xValue / 2.0; + double term = 1.0; + double sum = 1.0; + for (uint32_t k = 1U; k <= kI0MaxTerms; ++k) + { + const double ratio = half / static_cast(k); + term *= ratio * ratio; + sum += term; + if (term < sum * kI0ConvergeEps) + { + break; + } + } + return sum; + } + + /// Build the 8×24 windowed-sinc polyphase upsampler (flat, phase-major). + /// h[n] = L·(2·fc)·sinc(2·fc·(n−M))·kaiser(n,β), M = (N−1)/2. Off-RT. + inline void computeCoefficients(Coefficients& coeffs) noexcept + { + const double center = static_cast(kPrototypeN - 1U) / 2.0; // 95.5 + const double twoFc = 2.0 * kProtoCutoffNorm; // 0.125 = 1/L + const double scale = static_cast(kOversampling) * twoFc; // = 1.0 + const double i0Beta = kaiserI0(kKaiserBeta); + const double denom = static_cast(kPrototypeN - 1U); + + for (uint32_t i = 0U; i < kPrototypeN; ++i) + { + // dist is half-integer (center = 95.5) → sincArg is never exactly 0. + const double dist = static_cast(i) - center; + const double sincArg = twoFc * dist; + const double sincVal = + std::sin(std::numbers::pi * sincArg) / (std::numbers::pi * sincArg); + const double ratio = (2.0 * dist) / denom; + const double winArg = kKaiserBeta * std::sqrt(std::max(0.0, 1.0 - (ratio * ratio))); + const double window = kaiserI0(winArg) / i0Beta; + + const uint32_t phase = i % kOversampling; + const uint32_t tap = i / kOversampling; + coeffs[(static_cast(phase) * kNumTaps) + tap] = scale * sincVal * window; + } + } + + /// 8× polyphase inter-sample true peak of the NEWEST sample in `hist` (newest-first + /// window): runs the 8 phase dot-products, returns max |·|. The caller maxes this + /// over all active channels. + [[nodiscard]] inline auto phasePeak(const History& hist, const Coefficients& coeffs) noexcept + -> double + { + double maxPeak = 0.0; + for (uint32_t phase = 0U; phase < kOversampling; ++phase) + { + const size_t base = static_cast(phase) * kNumTaps; + double dot = 0.0; + for (uint32_t k = 0U; k < kNumTaps; ++k) + { + dot += coeffs[base + k] * hist[k]; + } + maxPeak = std::max(maxPeak, std::abs(dot)); + } + return maxPeak; + } +} // namespace AdaptiveSound::TruePeakKernel diff --git a/Sources/DesignTokenKit/GlassDecor.swift b/Sources/DesignTokenKit/GlassDecor.swift index 73e201b..dcb12ec 100644 --- a/Sources/DesignTokenKit/GlassDecor.swift +++ b/Sources/DesignTokenKit/GlassDecor.swift @@ -45,12 +45,14 @@ public enum GlassDecor { /// 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). + /// (PR 6) so the two carved surfaces are visually identical. S10.8 PR E (realigned): + /// slims 5 → 4 to the mock's slim-slider spec. + public static let carvedTrackHeight: Double = 4 + /// Carved track base fill (the groove). S10.8 PR E: dark lifts 8% → 13% (the slimmer + /// groove needs the stronger carve to stay visible on the denser panel). public static let carvedTrack = AppearancePair( light: .gray(0.0, alpha: 0.10), - dark: .gray(1.0, alpha: 0.08) + dark: .gray(1.0, alpha: 0.13) ) /// 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 @@ -66,6 +68,54 @@ public enum GlassDecor { /// 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: Capsule tab strip (S10.8 PR B — Realigned Target `png/01-toolbar.png`) + + /// The active tab capsule's teal glow — DARK-ONLY (grammar rule 6). Realigned spec: + /// `asTealMid 50%, radius 8, y 1` (#1FA893 = the iconFill mid stop). + public static let tabActiveGlowDark = RGBAColor(red: 31.0 / 255.0, green: 168.0 / 255.0, + blue: 147.0 / 255.0, alpha: 0.5) + public static let tabActiveGlowRadius: Double = 8 + public static let tabActiveGlowOffsetY: Double = 1 + /// Track inner padding (track height = capsule + 2× this) and inter-tab spacing. + public static let tabTrackPadding: Double = 3 + public static let tabSpacing: Double = 2 + /// Base active-capsule height (@ScaledMetric-scaled at the call site, realigned: 28pt). + public static let tabCapsuleBaseHeight: Double = 28 + + // MARK: Chrome/footer band styled glass (S10.8 PR G — realigned `png/01` + `png/06`) + + // Founder decision 2: fill STRATA, not real blur (nothing scrolls behind the bands). + // The sheen is dark-only (grammar rule 6 — light keeps the plain window + hairlines); + // the 1px specular line always sits on a band's TOP edge (the edge that catches light). + public static let bandSheenStrong: RGBAColor = .gray(1.0, alpha: 0.05) + public static let bandSheenWeak: RGBAColor = .gray(1.0, alpha: 0.02) + public static let bandSpecularDark: RGBAColor = .gray(1.0, alpha: 0.09) + /// The dark seam on the header's content-facing (bottom) edge. + public static let bandSeamDark: RGBAColor = .gray(0.0, alpha: 0.35) + + // MARK: Inspector floating card (S10.8 PR E — realigned `png/05`) + + /// The teal radial glow BEHIND/below the floating card (dark-only — grammar rule 6): + /// `asTealMid 22% → clear`, blurred, extending past the card's bottom edge so the empty + /// area under the hugged card reads intentional. + public static let inspectorGlowDark = RGBAColor(red: 31.0 / 255.0, green: 168.0 / 255.0, + blue: 147.0 / 255.0, alpha: 0.22) + public static let inspectorGlowBlur: Double = 18 + public static let inspectorGlowBleed: Double = 20 + public static let inspectorGlowRadius: Double = 200 + + // MARK: Playing-row mini equalizer (S10.8 PR D — realigned `png/04`) + + // Deterministic sine bars (the Realigned Target SUPERSEDED the spectrum-driven plan — + // recorded in s10-8-deviations-plan.md §B). Bars sit still at `eqBarMinScale` whenever + // playback is paused or Reduce Motion is on (the §3.4 pulseIsActive predicate). + public static let eqBarDurations: [Double] = [0.80, 1.05, 0.90] + public static let eqBarPhases: [Double] = [0.0, 0.4, 0.7] + public static let eqBarWidth: Double = 2.5 + public static let eqBarSpacing: Double = 1.5 + public static let eqBarContainerHeight: Double = 12 + public static let eqBarMinScale: Double = 0.34 + // 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 @@ -76,10 +126,10 @@ public enum GlassDecor { 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. + /// The ENHANCED badge's pulsing dot (realigned: 1.6 s cycle, opacity 1 → 0.35 — was + /// the 8a 0.4) — the phase animator runs each half-cycle. public static let pulseHalfCycleSeconds: Double = 0.8 - public static let pulseDimOpacity: Double = 0.4 + public static let pulseDimOpacity: Double = 0.35 /// Base badge capsule height (@ScaledMetric-scaled at the call site, 8a: 22pt). public static let badgeBaseHeight: Double = 22 diff --git a/Sources/DesignTokenKit/NowPlayingLayout.swift b/Sources/DesignTokenKit/NowPlayingLayout.swift index da70977..3927767 100644 --- a/Sources/DesignTokenKit/NowPlayingLayout.swift +++ b/Sources/DesignTokenKit/NowPlayingLayout.swift @@ -5,8 +5,10 @@ import Foundation public enum NowPlayingLayout { - /// The fixed trailing inspector column (founder decision D2 — 8a). - public static let inspectorWidth: Double = 260 + /// The fixed trailing inspector column (founder decision D2 — 8a). S10.8 PR E: 260 → + /// 320 per the Realigned Target's floating card (`png/05`); LAY-01 still clears the + /// 320pt queue minimum at the 880pt window (848 − 20 − 320 = 508). + public static let inspectorWidth: Double = 320 /// 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. diff --git a/Sources/DesignTokenKit/Palette.swift b/Sources/DesignTokenKit/Palette.swift index cc605a4..c4b5a00 100644 --- a/Sources/DesignTokenKit/Palette.swift +++ b/Sources/DesignTokenKit/Palette.swift @@ -48,12 +48,25 @@ public enum Palette { 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)) + /// Foreground ON the accent (play glyph over teal, active-tab text). S10.8 PR B + /// (Realigned Target): the near-black teal #0C1413 replaces white — dark-on-teal is the + /// realigned identity AND retires the long-flagged ≈2.5:1 white-on-teal (now ≥4.5:1 on + /// the accent and the gradient's bright/mid stops — gated by R4-TAB-01). + public static let onAccent = AppearancePair(both: RGBAColor( + red: 12.0 / 255.0, green: 20.0 / 255.0, blue: 19.0 / 255.0 + )) /// Alternate accent (swap-in blue). public static let blue = AppearancePair(both: RGBAColor(red: 0.039, green: 0.518, blue: 1.0)) // #0A84FF + /// Teal TEXT/glyph on dark surfaces and tinted chips (S10.8 PR C — the Realigned + /// Target's `asTealText` #6FE0D0). Light per the split-text-vs-fill pattern + /// (statusWarningText precedent): a deep teal that clears AA on the accent-tinted chip + /// over the light window (R4-CHIP-01). #0B5548 light. + public static let accentText = AppearancePair( + light: RGBAColor(red: 11.0 / 255.0, green: 85.0 / 255.0, blue: 72.0 / 255.0), + dark: RGBAColor(red: 111.0 / 255.0, green: 224.0 / 255.0, blue: 208.0 / 255.0) + ) + // MARK: Status // @@ -89,11 +102,39 @@ public enum Palette { dark: RGBAColor(red: 1.0, green: 0.623, blue: 0.039) ) + /// True-peak HOT meter fill (S10.8 PR E — realigned `png/05`: value > −1 dBTP turns the + /// meter tail + value amber). Dark = the realigned #F0B429; light per the split + /// text-vs-fill grammar: a darker amber (#B45309) that clears 3:1 non-text on the light + /// panel (R4-METER-01). A THIRD amber family member is deliberate — the deviations plan + /// sanctioned the realigned amber as a token; statusWarning keeps the macOS-vivid dot/badge + /// duty. + public static let meterHot = AppearancePair( + light: RGBAColor(red: 180.0 / 255.0, green: 83.0 / 255.0, blue: 9.0 / 255.0), + dark: RGBAColor(red: 240.0 / 255.0, green: 180.0 / 255.0, blue: 41.0 / 255.0) + ) + /// Its TEXT variant (the amber value readout): dark = the same #F0B429 (AA on the dark + /// panel); light = the existing statusWarningText dark-amber — DERIVED, not a new value. + public static let meterHotText = AppearancePair( + light: statusWarningText.light, + dark: RGBAColor(red: 240.0 / 255.0, green: 180.0 / 255.0, blue: 41.0 / 255.0) + ) + // MARK: Row tints (derived from accent — appearance-independent) - public static let rowNowPlaying = AppearancePair(both: accent.light.opacity(0.25)) + /// S10.8 PR D (Realigned Target `png/04`): the heavy 25% band becomes a SUBTLE 13% + /// tinted card (radius-10 + ring at the call site); the teal `accentTitle` + mini + /// equalizer now carry the row's prominence instead of fill strength. + public static let rowNowPlaying = AppearancePair(both: accent.light.opacity(0.13)) public static let rowSelected = AppearancePair(both: accent.light.opacity(0.12)) + /// The playing row's TITLE teal (S10.8 PR D — realigned #7EE8D8; one step brighter than + /// `accentText` so the title reads above the row's chips). Light: the same deep-teal + /// text family as `accentText` (audited on the 13% tint, R4-ROW-01). + public static let accentTitle = AppearancePair( + light: RGBAColor(red: 11.0 / 255.0, green: 85.0 / 255.0, blue: 72.0 / 255.0), + dark: RGBAColor(red: 126.0 / 255.0, green: 232.0 / 255.0, blue: 216.0 / 255.0) + ) + // MARK: Icon-fill gradient stops (app-mark squircle / play button — appearance-independent) /// The two upper stops of `DesignSystem.Gradient.iconFill` (#3FD0BA, #1FA893 as the @@ -121,12 +162,40 @@ public enum Palette { 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. + /// The inspector panel fill. S10.8 PR E (Realigned Target `png/05`): the floating card + /// densifies to `rgba(30,32,37,.72)` (was the 8a `rgba(30,33,38,.5)`) — it now hugs its + /// content and floats over the glow, so it needs more body than the old full-height + /// wall. Light per the grammar: white-based glass, unchanged. Same 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) + dark: RGBAColor(red: 30.0 / 255.0, green: 32.0 / 255.0, blue: 37.0 / 255.0, alpha: 0.72) + ) + + /// The capsule tab-strip track (S10.8 PR B — Realigned Target): a carved dark capsule on + /// BOTH appearances (the realigned toolbar keeps a dark track in light mode; per the §3.2 + /// grammar the light side is a LIGHTER dark wash — re-derived, never an inversion — so the + /// audited label hierarchy stays legible on it, R4-TAB-01). + public static let tabTrack = AppearancePair( + light: .gray(0.0, alpha: 0.22), + dark: .gray(0.0, alpha: 0.38) + ) + + // MARK: Small-control chips (S10.8 PR C — queue header; realigned `png/03`) + + /// Hovered chip fill — one notch above the resting `badgeFill` wash. + public static let controlHover = AppearancePair( + light: .gray(0.0, alpha: 0.10), + dark: .gray(1.0, alpha: 0.12) + ) + /// Toggled-on chip fill (repeat/shuffle active): accent-derived like the row tints + /// (TOK-04 asserts the derivation); the glyph on it is `accentText`. + public static let controlActiveFill = AppearancePair(both: accent.light.opacity(0.16)) + /// The selected segment of the mini capsule pair (Up Next / Recent). Light is SOLID + /// white (the realigned light mock's raised segment — the grammar's white-card move), + /// dark the 8a white-12% lift. + public static let segmentSelected = AppearancePair( + light: .gray(1.0), + dark: .gray(1.0, alpha: 0.12) ) // MARK: Ambient glow field (S10.7 PR 2 — design §3.3) @@ -162,6 +231,9 @@ public enum Palette { ("iconFillTop", iconFillTop), ("iconFillMid", iconFillMid), ("glowTeal", glowTeal), ("glowLime", glowLime), ("glowBlue", glowBlue), ("lensFill", lensFill), ("badgeFill", badgeFill), ("panelFill", panelFill), + ("tabTrack", tabTrack), ("accentText", accentText), ("controlHover", controlHover), + ("controlActiveFill", controlActiveFill), ("segmentSelected", segmentSelected), + ("accentTitle", accentTitle), ("meterHot", meterHot), ("meterHotText", meterHotText), ("glassRim", GlassDecor.rim), ("glassHairline", GlassDecor.glassHairline), ("glassShadow", GlassDecor.shadowColor), ("carvedTrack", GlassDecor.carvedTrack), ("knobFill", GlassDecor.knobFill), diff --git a/Tests/DSPKernelNullTest.cpp b/Tests/DSPKernelNullTest.cpp index 2df6c8b..1fd7327 100644 --- a/Tests/DSPKernelNullTest.cpp +++ b/Tests/DSPKernelNullTest.cpp @@ -72,7 +72,7 @@ namespace { - constexpr std::array kTests = {{ + constexpr std::array kTests = {{ // Phase 0 bypass tests {"IntensityZero_BitExactPassthrough", testIntensityZeroIsBitExact, true}, {"IntensityZero_MultiChunkBitExact", testIntensityZeroMultiChunk, true}, @@ -94,6 +94,8 @@ namespace {"Loudness_AbsoluteGate", testLoudnessAbsoluteGate, true}, {"Loudness_RelativeGate", testLoudnessRelativeGate, true}, {"Loudness_MakeupRoundTrip", testLoudnessMakeupRoundTrip, true}, + {"Loudness_TruePeakKernel_InterSample", testLoudnessTruePeakKernelInterSample, true}, + {"Loudness_MeterBridge_TruePeak", testLoudnessMeterBridgeTruePeak, true}, // EQ audibility tests (Sprint 5 — Milestone 2) {"EQ_FrequencyResponseAccuracy", testEQFrequencyResponseAccuracy, true}, {"EQ_CoefficientSwapNoClick", testEQCoefficientSwapNoClick, true}, diff --git a/Tests/DesignTokenKitTests/ControlChipAuditTests.swift b/Tests/DesignTokenKitTests/ControlChipAuditTests.swift new file mode 100644 index 0000000..6f13830 --- /dev/null +++ b/Tests/DesignTokenKitTests/ControlChipAuditTests.swift @@ -0,0 +1,65 @@ +// R4-CHIP — the queue-header control chips' contrast audit (S10.8 PR C). Own file, same +// posture as R4-TAB: shares only the `ratio`/threshold helpers with the base R4 suite. + +import DesignTokenKit +import Testing + +/// The realigned queue header (`png/03`): 28pt icon chips (resting `badgeFill`, hovered +/// `controlHover`, toggled-on `controlActiveFill` with an `accentText` glyph) and the mini +/// Up Next / Recent capsule pair (a `tabTrack` track with a `segmentSelected` lift). Chips +/// sit on the queue region of the glow field in dark; the plain-window composite is the +/// audit surface (the field's teal core never hosts the header — but the D8 corner suite +/// already bounds field brightening for fills of this class via the badge pair). +@Suite("Contrast audit — queue header control chips (R4-CHIP)") +struct ControlChipAuditTests { + @Test("R4-CHIP-01: chip glyphs and segment text clear AA on every chip state") + func chipStates() { + for appearance in TokenAppearance.allCases { + let window = Palette.window.value(for: appearance) + // Resting + hovered chips carry labelSecondary glyphs (label on hover-brighten). + for (fillName, fill) in [("badgeFill", Palette.badgeFill), + ("controlHover", Palette.controlHover)] { + let chip = fill.value(for: appearance).over(window) + for (name, label) in [("label", Palette.label), + ("labelSecondary", Palette.labelSecondary)] { + let ratio = ContrastAuditTests.ratio(label: label, on: chip, appearance) + #expect(ratio >= ContrastAuditTests.textAA, + "\(name) on \(fillName)⊕window (\(appearance)) = \(ratio)") + } + } + // Toggled-on chip: the accentText glyph on the accent-16% tint. + let active = Palette.controlActiveFill.value(for: appearance).over(window) + let activeRatio = ContrastAuditTests.ratio(label: Palette.accentText, + on: active, appearance) + #expect(activeRatio >= ContrastAuditTests.textAA, + "accentText on controlActiveFill⊕window (\(appearance)) = \(activeRatio)") + // Selected segment: label on segmentSelected ⊕ tabTrack ⊕ window. (Unselected + // text on the bare track is R4-TAB-01's pair.) + let track = Palette.tabTrack.value(for: appearance).over(window) + let segment = Palette.segmentSelected.value(for: appearance).over(track) + let segRatio = ContrastAuditTests.ratio(label: Palette.label, on: segment, appearance) + #expect(segRatio >= ContrastAuditTests.textAA, + "label on segmentSelected⊕track (\(appearance)) = \(segRatio)") + } + } + + /// S10.8 PR F: the hero's realigned ENHANCED chip is the same `controlActiveFill` + + /// `accentText` pair, but it sits in the hero — the glow field's TEAL CORE (the + /// field's brightest text seat), so it gets the sampled-field audit like R4-BADGE-01. + @Test("R4-CHIP-02: hero teal chip text clears AA over the sampled glow field (dark)") + func heroTealChipOverGlow() { + for geometry in ContrastAuditTests.glowGeometries { + for point in ContrastAuditTests.gridPoints() { + let glow = GlowFieldSpec.compositeBackdrop( + unitX: point.x, unitY: point.y, + containerWidth: geometry.width, containerHeight: geometry.height, + appearance: .dark + ) + let chip = Palette.controlActiveFill.dark.over(glow) + let ratio = ContrastAuditTests.ratio(label: Palette.accentText, on: chip, .dark) + #expect(ratio >= ContrastAuditTests.textAA, + "accentText on tealChip⊕glow @(\(point.x),\(point.y)) \(geometry.width)pt = \(ratio)") + } + } + } +} diff --git a/Tests/DesignTokenKitTests/MeterAuditTests.swift b/Tests/DesignTokenKitTests/MeterAuditTests.swift new file mode 100644 index 0000000..5c212c1 --- /dev/null +++ b/Tests/DesignTokenKitTests/MeterAuditTests.swift @@ -0,0 +1,46 @@ +// R4-METER — the true-peak hot state's contrast audit (S10.8 PR E). Own file, same posture +// as R4-TAB/R4-CHIP/R4-ROW: shares the `ratio`/threshold/grid helpers with the base suite. + +import DesignTokenKit +import Testing + +/// The hot meter (`png/05`): an amber fill tail (non-text, 3:1) and an amber value readout +/// (text, 4.5:1), both sitting on the inspector panel — audited over the panel's real +/// backdrops: panel⊕glow sampled on the RIGHT half (the inspector's home, per R4-PANEL-01's +/// domain) in dark, and the plain-window/opaque composites both appearances. +@Suite("Contrast audit — true-peak hot meter (R4-METER)") +struct MeterAuditTests { + @Test("R4-METER-01: hot fill clears non-text and hot text clears AA on the panel") + func hotMeterOnPanel() { + // Dark: panel ⊕ the sampled glow field, right half. + for geometry in ContrastAuditTests.glowGeometries { + for point in ContrastAuditTests.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) + assertHotPairs(on: panel, appearance: .dark, + where: "panel⊕glow @(\(point.x),\(point.y))") + } + } + // Both appearances: the plain-window composite and the RT/IC opaque fallback. + for appearance in TokenAppearance.allCases { + let window = Palette.window.value(for: appearance) + let panel = Palette.panelFill.value(for: appearance).over(window) + assertHotPairs(on: panel, appearance: appearance, where: "panel⊕window") + } + } + + private func assertHotPairs(on surface: RGBAColor, appearance: TokenAppearance, + where site: String) { + let fill = Palette.meterHot.value(for: appearance).over(surface) + #expect(RGBAColor.contrastRatio(fill, surface) >= ContrastAuditTests.nonTextAA, + "meterHot fill on \(site) (\(appearance))") + let textRatio = ContrastAuditTests.ratio(label: Palette.meterHotText, + on: surface, appearance) + #expect(textRatio >= ContrastAuditTests.textAA, + "meterHotText on \(site) (\(appearance)) = \(textRatio)") + } +} diff --git a/Tests/DesignTokenKitTests/PlayingRowAuditTests.swift b/Tests/DesignTokenKitTests/PlayingRowAuditTests.swift new file mode 100644 index 0000000..93ace52 --- /dev/null +++ b/Tests/DesignTokenKitTests/PlayingRowAuditTests.swift @@ -0,0 +1,40 @@ +// R4-ROW — the realigned playing row's contrast audit (S10.8 PR D). Own file, same posture +// as R4-TAB/R4-CHIP: shares the `ratio`/threshold/grid helpers with the base R4 suite. + +import DesignTokenKit +import Testing + +/// The playing row (`png/04`): a 13% accent card carrying the teal `accentTitle` and an +/// `accentText` duration. The queue is the LEFT flex region, so the card can sit anywhere +/// over the glow field (incl. the teal core — jump-to-now-playing centers it there); the +/// title/duration are audited at every sampled point like R4-GLOW-02's label pairs. +@Suite("Contrast audit — playing row (R4-ROW)") +struct PlayingRowAuditTests { + @Test("R4-ROW-01: accentTitle + accentText clear AA on the 13% card over its backdrops") + func playingRowText() { + // Dark: card ⊕ the real sampled glow field, every grid point (both geometries). + for geometry in ContrastAuditTests.glowGeometries { + for point in ContrastAuditTests.gridPoints() { + let glow = GlowFieldSpec.compositeBackdrop( + unitX: point.x, unitY: point.y, + containerWidth: geometry.width, containerHeight: geometry.height, + appearance: .dark + ) + let card = Palette.rowNowPlaying.dark.over(glow) + for (name, text) in [("accentTitle", Palette.accentTitle), + ("accentText", Palette.accentText)] { + let ratio = ContrastAuditTests.ratio(label: text, on: card, .dark) + #expect(ratio >= ContrastAuditTests.textAA, + "\(name) on card⊕glow @(\(point.x),\(point.y)) \(geometry.width)pt = \(ratio)") + } + } + } + // Light: glows are suppressed — the card composites over the plain window. + let lightCard = Palette.rowNowPlaying.light.over(Palette.window.light) + for (name, text) in [("accentTitle", Palette.accentTitle), + ("accentText", Palette.accentText)] { + let ratio = ContrastAuditTests.ratio(label: text, on: lightCard, .light) + #expect(ratio >= ContrastAuditTests.textAA, "\(name) on light card = \(ratio)") + } + } +} diff --git a/Tests/DesignTokenKitTests/TabStripAuditTests.swift b/Tests/DesignTokenKitTests/TabStripAuditTests.swift new file mode 100644 index 0000000..82c9861 --- /dev/null +++ b/Tests/DesignTokenKitTests/TabStripAuditTests.swift @@ -0,0 +1,40 @@ +// R4-TAB — the capsule tab strip's contrast audit (S10.8 PR B). Its own FILE (not just its +// own suite): ContrastAuditTests.swift sits at the SwiftLint file-length limit, and this +// audit shares only the `ratio`/threshold helpers with the base suite. + +import DesignTokenKit +import Testing + +/// The realigned tab strip: inactive/hover text sits on the carved `tabTrack` composited +/// over the chrome band (the plain window, D4); active text is the new dark-on-teal +/// `onAccent` on the iconFill gradient. The gradient's DEEP bottom stop is deliberately not +/// audited as a text backdrop: the capsule's glyphs sit on the bright/mid span (top-to-center +/// of a 28pt capsule); the deep stop is the bottom rim under the baseline. +@Suite("Contrast audit — capsule tab strip (R4-TAB)") +struct TabStripAuditTests { + @Test("R4-TAB-01: tab text clears AA on the track and on the active teal capsule") + func tabStripText() { + for appearance in TokenAppearance.allCases { + let window = Palette.window.value(for: appearance) + let track = Palette.tabTrack.value(for: appearance).over(window) + // Inactive (labelSecondary) + hovered (label) text on the carved track. + for (name, label) in [("labelSecondary", Palette.labelSecondary), + ("label", Palette.label)] { + let ratio = ContrastAuditTests.ratio(label: label, on: track, appearance) + #expect(ratio >= ContrastAuditTests.textAA, + "\(name) on tabTrack⊕window (\(appearance)) = \(ratio)") + } + // Active text (#0C1413) on the stops its glyphs actually sit on — this is also + // the audit that retires the old flagged 2.5:1 white-on-accent (play glyphs). + for (stopName, stop) in [("iconFillTop", Palette.iconFillTop), + ("iconFillMid", Palette.iconFillMid), + ("accent", Palette.accent)] { + let backdrop = stop.value(for: appearance) + let text = Palette.onAccent.value(for: appearance).over(backdrop) + let ratio = RGBAColor.contrastRatio(text, backdrop) + #expect(ratio >= ContrastAuditTests.textAA, + "onAccent on \(stopName) (\(appearance)) = \(ratio)") + } + } + } +} diff --git a/Tests/DesignTokenKitTests/TokenInvariantTests.swift b/Tests/DesignTokenKitTests/TokenInvariantTests.swift index b65b6d9..9a62cd8 100644 --- a/Tests/DesignTokenKitTests/TokenInvariantTests.swift +++ b/Tests/DesignTokenKitTests/TokenInvariantTests.swift @@ -47,16 +47,20 @@ struct TokenInvariantTests { } } - /// rowNowPlaying/rowSelected are the accent at documented alphas, never drifting values. - @Test("TOK-04: derived row tints stay derived from the accent") + /// rowNowPlaying/rowSelected/controlActiveFill are the accent at documented alphas, + /// never drifting values. + @Test("TOK-04: derived accent tints stay derived from the accent") func rowTintsDeriveFromAccent() { let accent = Palette.accent.light - #expect(Palette.rowNowPlaying.light == accent.opacity(0.25)) + #expect(Palette.rowNowPlaying.light == accent.opacity(0.13)) #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)") + #expect(Palette.controlActiveFill.light == accent.opacity(0.16)) + for (name, pair) in [("rowNowPlaying", Palette.rowNowPlaying), + ("rowSelected", Palette.rowSelected), + ("controlActiveFill", Palette.controlActiveFill)] { + #expect(pair.light == pair.dark, + "\(name) is appearance-independent (accent-derived)") + } } /// The 8a concentric chain (§3.2): outer radii are never smaller than inner ones. diff --git a/Tests/LoudnessTests.inc b/Tests/LoudnessTests.inc index 822d614..7d4c1c1 100644 --- a/Tests/LoudnessTests.inc +++ b/Tests/LoudnessTests.inc @@ -159,3 +159,107 @@ static auto testLoudnessMakeupRoundTrip() -> void } logPass(kName); } + +// Test: the shared TruePeakKernel (S10.8 PR E — now also the METER path's detector) +// recovers the inter-sample peak of a quarter-rate sine sampled at ±45° — the classic +// ISP case: every SAMPLE lands at A·sin(45°) ≈ 0.7071·A, so a sample-peak meter +// under-reads by 3.01 dB while the true peak is A. Budget 0.35 dB (the same +// interpolator-gap class the libebur128 oracle uses). +static auto testLoudnessTruePeakKernelInterSample() -> void +{ + static const char* const kName = "Loudness_TruePeakKernel_InterSample"; + using namespace AdaptiveSound::TruePeakKernel; + + Coefficients coeffs{}; + computeCoefficients(coeffs); + + constexpr double kAmp = 0.5; // −6.02 dBTP true peak + constexpr double kTruePeakDb = -6.0206; + History hist{}; + double maxIsp = 0.0; + double maxSample = 0.0; + for (uint32_t n = 0U; n < 4800U; ++n) + { + // fs/4 sine, +45° phase: samples alternate ±kAmp·0.7071 exactly between peaks. + const double s = kAmp + * std::sin((std::numbers::pi / 2.0 * static_cast(n)) + (std::numbers::pi / 4.0)); + for (uint32_t k = kNumTaps - 1U; k > 0U; --k) + { + hist[k] = hist[k - 1U]; + } + hist[0] = s; + maxSample = std::max(maxSample, std::abs(s)); + maxIsp = std::max(maxIsp, phasePeak(hist, coeffs)); + } + + const double ispDb = 20.0 * std::log10(maxIsp); + const double sampleDb = 20.0 * std::log10(maxSample); + // The sample grid must actually miss the crest (the premise of the test)… + if (sampleDb > kTruePeakDb - 2.5) + { + std::ostringstream oss; + oss << "test premise broken: sample peak " << sampleDb << " dB is too close to the crest"; + logFail(kName, oss.str()); + return; + } + // …and the kernel must recover it. + if (std::abs(ispDb - kTruePeakDb) > 0.35) + { + std::ostringstream oss; + oss << "true peak: expected " << kTruePeakDb << " dBTP, kernel read " << ispDb + << " (sample peak " << sampleDb << ")"; + logFail(kName, oss.str()); + return; + } + logPass(kName); +} + +// The C-ABI surface under test below (the harness compiles LoudnessMeterBridge.cpp). +#include "../Sources/AudioDSP/include/DeviceBridge.h" + +// Test: the C-ABI meter bridge end-to-end (S10.8 PR E break-it rider) — the tap-thread +// path the kernel test above does NOT cover: per-sample shift-register continuity across +// buffer boundaries, the MONO aliasing path (right == nullptr), and the dBTP mapping. +// Same fs/4 +45° signal: the bridge's truePeakDb must recover −6.02 dBTP even though +// every sample the bridge sees is 3 dB below the crest. +static auto testLoudnessMeterBridgeTruePeak() -> void +{ + static const char* const kName = "Loudness_MeterBridge_TruePeak"; + void* meter = loudnessMeterCreate(TestConstants::kSampleRate48k); + if (meter == nullptr) + { + logFail(kName, "loudnessMeterCreate returned null"); + return; + } + + constexpr double kAmp = 0.5; // −6.02 dBTP true peak + constexpr double kTruePeakDb = -6.0206; + constexpr uint32_t kChunk = 512U; // multiple buffers → history must carry across calls + std::array buf{}; + uint32_t n = 0U; + for (uint32_t chunk = 0U; chunk < 8U; ++chunk) + { + for (uint32_t i = 0U; i < kChunk; ++i, ++n) + { + buf[i] = static_cast( + kAmp + * std::sin((std::numbers::pi / 2.0 * static_cast(n)) + + (std::numbers::pi / 4.0))); + } + loudnessMeterAddStereo(meter, buf.data(), nullptr, kChunk); // MONO path + } + + const CLoudnessReadout out = loudnessMeterRead(meter); + loudnessMeterDestroy(meter); + + // Decay headroom: the peak recurs every 4 samples, so per-buffer decay (0.85) never + // erodes the latch below the budget within a run this dense. + if (std::abs(out.truePeakDb - kTruePeakDb) > 0.4) + { + std::ostringstream oss; + oss << "bridge true peak: expected " << kTruePeakDb << " dBTP, read " << out.truePeakDb; + logFail(kName, oss.str()); + return; + } + logPass(kName); +} diff --git a/docs/design/now-playing-realigned/README.md b/docs/design/now-playing-realigned/README.md new file mode 100644 index 0000000..0b7ea35 --- /dev/null +++ b/docs/design/now-playing-realigned/README.md @@ -0,0 +1,37 @@ +# AdaptiveSound — Now Playing realignment package + +**For: a Claude session (Opus) in VS Code, repo `ramith/sound-engineering`.** +This package supersedes the earlier DEVIATIONS.md plan. The visual truth is NOT the old 7a mock anymore — it is the **Realigned Target**, which keeps the layout the app already has and applies the designed styling on top of it. Do not restructure the layout. + +## What is in this folder + +``` +README.md ← you are here +REALIGN_GUIDE.md ← the instructions. Follow it top to bottom, one PR at a time. +png/00-full-window.png ← the whole target screen, 1440×920 +png/01-toolbar.png ← PR-B + PR-G target +png/02-badges.png ← PR-F target +png/03-queue-header.png ← PR-C target +png/04-playing-row.png ← PR-D target +png/05-inspector.png ← PR-E target +png/06-transport.png ← PR-G target +html/Now Playing - Realigned Target.dc.html ← live interactive mock (open in a browser, +html/support.js keep both files in the same folder) +``` + +## How to use this package + +1. Copy this whole folder into the repo at `docs/design/now-playing-realigned/`. +2. Open `png/00-full-window.png` and keep it visible while you work. Every pixel decision is answered by a PNG before it is answered by prose. +3. Follow `REALIGN_GUIDE.md`. It is ordered PR-A → PR-G. **One PR = one commit/branch.** Do not combine PRs. +4. After every PR: build, run both light and dark appearance, run `scripts/strict-gate.sh`. + +## Opening prompt (paste this into the Claude session) + +> Restyle the Now Playing screen to match the target design in `docs/design/now-playing-realigned/`. Read `README.md` and `REALIGN_GUIDE.md` first, and look at every PNG in `png/` before writing code. Work one PR at a time in the order the guide gives (PR-A through PR-G), one branch/commit per PR. Rules: do NOT change the layout structure (toolbar / hero+analyzer / queue+inspector / transport bar stays exactly where it is); every new color goes through `DesignSystem` tokens with a light-mode variant; respect Reduce Motion (no pulsing dot, no equalizer animation, no spectrum animation) and Reduce Transparency (opaque fallback fills); keep all existing accessibility labels and bindings; `scripts/strict-gate.sh` must pass before each commit. + +## Ground rules (repeated because they matter) + +- **Styling only.** The current build's layout skeleton is correct. If a change requires moving a view to a different parent, stop and re-read the guide — only PR-E (inspector hugging its content) changes any frame behavior. +- **Colors**: teal family only — `#3FD0BA` (bright), `#1FA893` (mid), `#14897A` (deep), text-on-teal `#0C1413`, teal text `#6FE0D0` / `#7EE8D8`. Amber warning `#F0B429`. Never introduce a new hue. +- **"Glass" here is styled, not blurred** for the top and bottom bars (nothing scrolls behind them). Only the floating inspector card uses a real material/blur. diff --git a/docs/design/now-playing-realigned/REALIGN_GUIDE.md b/docs/design/now-playing-realigned/REALIGN_GUIDE.md new file mode 100644 index 0000000..5685353 --- /dev/null +++ b/docs/design/now-playing-realigned/REALIGN_GUIDE.md @@ -0,0 +1,253 @@ +# REALIGN GUIDE — Now Playing, PR by PR + +Every PR below has: **(1)** what you will see change, **(2)** which file to edit, **(3)** the exact target values, **(4)** a SwiftUI code sample you can adapt, **(5)** a done-checklist. File paths were correct at commit `82bc287` — if a view moved, search for the type name, don't guess. + +Shared tokens — add these to `DesignSystem` first if missing (PR-A): + +```swift +// DesignSystem+Realign.swift (new file, Sources/AdaptiveSound/UI/DesignSystem/) +extension Color { + static let asTealBright = Color(hex: 0x3FD0BA) + static let asTealMid = Color(hex: 0x1FA893) + static let asTealDeep = Color(hex: 0x14897A) + static let asTealText = Color(hex: 0x6FE0D0) // teal text on dark + static let asTealTitle = Color(hex: 0x7EE8D8) // playing-row title + static let asOnTeal = Color(hex: 0x0C1413) // dark text on teal fill + static let asAmber = Color(hex: 0xF0B429) // true-peak warning +} +extension LinearGradient { + static let asTealButton = LinearGradient( + colors: [.asTealBright, .asTealMid, .asTealDeep], + startPoint: .top, endPoint: .bottom) + static let asTealMeter = LinearGradient( + colors: [.asTealMid, .asTealBright], + startPoint: .leading, endPoint: .trailing) +} +``` + +A reusable "styled glass" bar modifier used by PR-B and PR-G (no real blur — a fill plus one light hairline): + +```swift +struct StyledGlassBar: ViewModifier { // top bar: lightFrom = .top + var lightFrom: UnitPoint = .top // bottom bar: lightFrom = .bottom + func body(content: Content) -> some View { + content + .background(LinearGradient( + colors: [Color.white.opacity(0.05), Color.white.opacity(0.02)], + startPoint: lightFrom, + endPoint: lightFrom == .top ? .bottom : .top)) + .overlay(Rectangle().fill(Color.white.opacity(0.09)).frame(height: 1), + alignment: lightFrom == .top ? .top : .top) + // the 1px specular line ALWAYS sits on the top edge of the bar + } +} +``` + +--- + +## PR-A — tokens (do this first) + +1. Add the extension above. 2. Provide light-mode variants in the asset catalog or via `@Environment(\.colorScheme)` the same way existing tokens do. 3. Nothing visual changes yet. Checklist: builds, strict-gate passes. + +--- + +## PR-B — toolbar tab strip → `png/01-toolbar.png` + +**File:** `Sources/AdaptiveSound/UI/Shell/ChromeBar.swift` (`TabSelectorView`) + +**What changes:** the native segmented picker becomes a dark capsule track; the active tab is a glowing teal capsule with dark text; inactive tabs lighten on hover. + +Target values: +- Track: height 34, corner radius 17 (capsule), fill `black 38%`, inner shadow (dark, y 1, blur 2), inner padding 3, tab spacing 2. +- Active tab: height 28, capsule, fill teal `#29B6A4` at 94% (or `asTealBright→asTealMid` vertical gradient), text `asOnTeal` bold 12.5pt, glow shadow `asTealMid 50%, radius 8, y 1`, plus a 1px white-30% highlight along its top inside edge. +- Inactive tab: text `white 60%` semibold 12.5pt; on hover fill `white 6%` and text `white 90%`. + +```swift +struct TabCapsuleStrip: View { + @Binding var selection: MainTab + @State private var hovered: MainTab? + var body: some View { + HStack(spacing: 2) { + ForEach(MainTab.allCases) { tab in + let active = tab == selection + Text(tab.title) + .font(.system(size: 12.5, weight: active ? .bold : .semibold)) + .foregroundStyle(active ? Color.asOnTeal + : Color.white.opacity(hovered == tab ? 0.9 : 0.6)) + .padding(.horizontal, 15).frame(height: 28) + .background { + if active { + Capsule().fill(LinearGradient.asTealButton) + .overlay(Capsule().stroke(Color.white.opacity(0.3), lineWidth: 1) + .blendMode(.plusLighter).mask( + Capsule().fill(LinearGradient(colors: [.white, .clear], + startPoint: .top, endPoint: .center)))) + .shadow(color: Color.asTealMid.opacity(0.5), radius: 8, y: 1) + } else if hovered == tab { + Capsule().fill(Color.white.opacity(0.06)) + } + } + .contentShape(Capsule()) + .onHover { hovered = $0 ? tab : nil } + .onTapGesture { selection = tab } + .accessibilityAddTraits(active ? [.isSelected] : []) + } + } + .padding(3) + .background(Capsule().fill(Color.black.opacity(0.38)) + .shadow(color: .black.opacity(0.45), radius: 2, y: 1)) // inner-ish shadow ok + } +} +``` + +Keep the existing keyboard navigation / accessibility labels from the picker. Animate selection change with `.animation(.snappy(duration: 0.18), value: selection)` — gate with Reduce Motion. + +**Also in this PR** (same file, small): device pill — put the `48 kHz` mono readout *inside* the pill after a chevron, capsule radius, fill `white 8%` + top inner highlight `white 10%`; width ~240pt so "MacBook Pro Speakers" doesn't truncate. + +Checklist: tabs match `png/01-toolbar.png`; hover works; VoiceOver reads tabs; strict-gate passes. + +--- + +## PR-C — queue header consolidation → `png/03-queue-header.png` + +**Files:** `Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift`, `PlaylistItemRow.swift` + +**What changes:** everything above the list collapses into ONE 32pt-tall row, and drag handles become hover-only. + +One row, left → right (see the PNG): +1. `QUEUE` — 13pt heavy, letter-spacing wide, `white 92%`. +2. `6 tracks` — 11.5pt mono, `white 40%`. +3. Three 28×28 icon buttons, radius 8, fill `white 6%` (hover `white 10%`): trash, shuffle, repeat. A toggled-on button (e.g. repeat active) uses fill `asTealMid 16%` + 1px ring `asTealMid 30%`, icon `asTealText`. +4. Up Next / Recent segmented pair: small capsule track (`black 35%`, padding 2), selected segment `white 12%` fill + bold `white 92%` text, unselected `white 55%`. 24pt high. +5. Spacer. +6. Filter pill, right-aligned: **190pt wide, 28pt high**, capsule, fill `white 7%`, magnifier icon + placeholder "Filter queue" at `white 40%`. This replaces the current full-width search bar. It is still a real `TextField`. + +Delete the old floating "QUEUE / 6 tracks" block, the separately-floating action buttons, and the full-width filter bar. + +**Drag handles:** in `PlaylistItemRow`, wrap the `≡` handle in `.opacity(isRowHovered ? 0.45 : 0)` with `.onHover` on the row and a 0.15s ease animation (skip animation under Reduce Motion). Reordering must still work while hidden — hover reveals it. + +Checklist: header is a single row matching the PNG; filter still filters; drag-reorder still works; no leftover empty vertical space above the list. + +--- + +## PR-D — playing row → `png/04-playing-row.png` + +**File:** `PlaylistItemRow.swift` + +**What changes:** the heavy teal band + ▶ becomes a subtle tinted card, and the track number becomes 3 dancing bars. + +Target values for the row whose track is currently playing: +- Fill `asTealMid` at **13%**, corner radius **10**, ring: 1px stroke `asTealMid 38%` (inset). +- Title: `asTealTitle` (#7EE8D8), weight semibold (650), 13.5pt. +- Format badge on this row: text `asTealText`, fill `asTealMid 18%` (other rows keep `white 45%` on `white 6%`). +- Where the number was: three vertical bars, each 2.5pt wide, radius 1, color `asTealBright`, container 12pt tall, 1.5pt gaps. Each bar animates `scaleY` between 0.34 and 1.0 (anchor bottom), ease-in-out, repeat forever, durations 0.8 / 1.05 / 0.9 s with staggered phase. +- The bars go **still (all at scaleY 0.34)** when playback is paused OR Reduce Motion is on. No ▶ triangle anywhere. + +```swift +struct MiniEqualizer: View { + var animating: Bool + var body: some View { + HStack(alignment: .bottom, spacing: 1.5) { + EqBar(duration: 0.80, phase: 0.0, animating: animating) + EqBar(duration: 1.05, phase: 0.4, animating: animating) + EqBar(duration: 0.90, phase: 0.7, animating: animating) + }.frame(width: 10.5, height: 12) + } +} +// TimelineView(.animation) + sin() — pauses cleanly, respects Reduce Motion: +struct EqBar: View { + let duration: Double; let phase: Double; let animating: Bool + var body: some View { + TimelineView(.animation(paused: !animating)) { ctx in + let t = ctx.date.timeIntervalSinceReferenceDate + let s = animating ? 0.34 + 0.66 * (0.5 + 0.5 * sin((t / duration + phase) * 2 * .pi)) : 0.34 + RoundedRectangle(cornerRadius: 1).fill(Color.asTealBright) + .frame(width: 2.5).scaleEffect(x: 1, y: s, anchor: .bottom) + } + } +} +``` + +Pass `animating: player.isPlaying && !reduceMotion`. + +Checklist: matches the PNG; bars stop on pause; row still selectable/tooltipped; other rows unchanged. + +--- + +## PR-E — inspector panel → `png/05-inspector.png` + +**Files:** `NowPlayingInfoView.swift`, `MasterGainSliderView.swift`, `LoudnessMetersView.swift`, container `RightPanelView.swift` / `NowPlayingTabView.swift` + +Two independent changes — keep them as two commits inside this PR if easier. + +**E1 — floating card.** The panel stops stretching to the window bottom. +- In the container, change the panel's alignment so it hugs content: put it in a `VStack { panel; Spacer(minLength: 0) }` or apply `.frame(maxHeight: .infinity, alignment: .top)` to the *wrapper*, never a fixed height on the panel. +- Panel styling: width 320, corner radius 18, fill dark `rgba(30,32,37)` at ~72% **with real material** (`.ultraThinMaterial` tinted dark, or the repo's existing glass recipe), 1px top inner highlight `white 13%`, hairline ring `white 6%`, drop shadow `black 60%, radius 25, y 18`. +- Behind/below the card: a blurred teal radial glow (`asTealMid 22% → clear`, blur ~18) so the empty area under the card reads intentional. It sits *behind* the card, extends ~20pt past its bottom. + +**E2 — loudness meters.** Each of the three rows becomes: label (82pt column) + 4pt meter bar + right-aligned mono value. +- Integrated `−15.9 LUFS` → meter 62% filled, teal gradient. +- Short-term `−24.3 LUFS` → meter 41% filled, teal gradient. +- Peak: **first upgrade the measurement to true peak (4× oversampled inter-sample peak) in the audio engine, THEN rename the label to "True peak"** and show value in dBTP. When value > −1.0 dBTP: the meter's last ~8% and the value text turn `asAmber`. Below that: plain teal. + +```swift +struct LoudnessRow: View { + let label: String; let valueText: String + let fraction: Double // 0…1 meter fill + let hot: Bool // amber state (true peak only) + var body: some View { + HStack(spacing: 10) { + Text(label).font(.system(size: 12)).foregroundStyle(.white.opacity(0.65)) + .frame(width: 82, alignment: .leading) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.white.opacity(0.10)) + Capsule().fill(hot + ? AnyShapeStyle(LinearGradient(colors: [.asTealBright, .asAmber], + startPoint: .leading, endPoint: .trailing)) + : AnyShapeStyle(LinearGradient.asTealMeter)) + .frame(width: geo.size.width * fraction) + } + }.frame(height: 4) + Text(valueText).font(.system(size: 11.5, weight: .semibold, design: .monospaced)) + .foregroundStyle(hot ? Color.asAmber : .white.opacity(0.85)) + } + } +} +``` + +**Also in this PR** (small, same files): master gain value must be signed (`+4.0 dB`); sliders get the slim custom style — 4pt carved track (`white 13%`), teal gradient fill, 15pt white round knob with drop shadow; headphones hint is the single line "Connect headphones to enable." with the long explanation moved to `.help()`; disabled headphone block at 50% opacity; section dividers are 1px `white 7%`. + +Checklist: card height = content height with glow visible below; meters match PNG; amber only when > −1 dBTP; label says "True peak" only after the DSP truly measures it. + +--- + +## PR-F — hero badges → `png/02-badges.png` + +**File:** hero view (evolved from `NowPlayingInfoView` / `NowPlayingWidget`) + +Replace the three chips `ENHANCED` `48 kHz` `20 %` with exactly two: + +1. **`● ENHANCED · 20%`** — teal chip: text `asTealText` bold 10.5pt letter-spaced, fill `asTealMid 16%`, 1px ring `asTealMid 28%`, radius 9, padding 11×5. The `●` is a 6pt teal dot with a soft teal glow that pulses opacity 1.0 → 0.35, 1.6s ease-in-out, forever (frozen at 1.0 under Reduce Motion). The `20%` is the live intensity value — one string, `ENHANCED · \(intensity)%`. +2. **`MP3 · 48 kHz`** — grey chip, mono font: text `white 60%` bold 10.5pt, fill `white 7%`, 1px ring `white 7%`, radius 9, same padding. `MP3` is the current track's file format (uppercase of the file extension / codec) — this adds the format, which the hero is currently missing. `48 kHz` is the engine sample rate readout already shown elsewhere. + +Checklist: exactly two chips, values live-update on track change and intensity change, dot doesn't pulse with Reduce Motion. + +--- + +## PR-G — glass on top + bottom bars → `png/01-toolbar.png` and `png/06-transport.png` + +**Files:** `ChromeBar.swift` (top), `Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift` (bottom) + +Apply the `StyledGlassBar` modifier from PR-A. **No real blur** — nothing scrolls behind these bars. +- Top bar: gradient `white 5% → white 2%` top-to-bottom, 1px `white 9%` line on its TOP edge, 1px `black 35%` line on its bottom edge. +- Bottom bar: gradient `white 5% → white 2%` bottom-to-top (light source flipped), 1px `white 9%` line on its TOP edge (the edge that catches light), subtle inner shade below it. +- Bottom bar extras (see `png/06-transport.png`): play button gets an inner top highlight (`white 35%` 1px) + teal glow shadow; scrubber = 4pt track `white 14%`, teal gradient fill, 13pt white knob; right-side readout `● Enhanced · 48 kHz` — dot pulses like the hero badge (same Reduce Motion gate), "Enhanced" in `asTealText`. + +Checklist: both bars match PNGs in both appearances; Reduce Transparency swaps any material for an opaque fill; strict-gate passes. + +--- + +## Final pass + +Open `html/Now Playing - Realigned Target.dc.html` in a browser next to the running app at 1440×920 and compare region by region (toolbar → hero → queue header → rows → inspector → transport). The mock has two toggles in its Tweaks: **reduceMotion** (what the app must look like with Reduce Motion on) and **peakHot** (amber vs calm true-peak state). The app must be able to show all four combinations. diff --git a/docs/design/now-playing-realigned/html/Now Playing - Realigned Target.dc.html b/docs/design/now-playing-realigned/html/Now Playing - Realigned Target.dc.html new file mode 100644 index 0000000..f3af5ab --- /dev/null +++ b/docs/design/now-playing-realigned/html/Now Playing - Realigned Target.dc.html @@ -0,0 +1,263 @@ + + + + + + + + + + + + + +
+
+
+ +
+ + + + Adaptive Sound +
+ + +
+
+ +
+
+ + MacBook Pro Speakers + + 48 kHz +
+
+ + + + + +
+
+ + +
+
+
+
Yeh Jeevan Hai Is Jeevan Ka
+
Kishore Kumar
+
+ +
+ ENHANCED · 20% + MP3 · 48 kHz +
+
+
+
+ 0 dB +
+ +
+
+
+
+ 20 Hz200 Hz2 kHz20 kHz +
+
+
+ + +
+
+ +
+ QUEUE + 6 tracks +
+ + + +
+
+ + +
+
+ + Filter queue +
+
+
+ +
+ + + + + + + + + + + {{ tk.n }} + + + {{ tk.title }} + MP3 + {{ tk.dur }} +
+
+
+
+ + +
+
+
+
+
+ MASTER GAIN + +4.0 dB +
+
+
+
+
+
+
+
+ INTENSITY + 20 % +
+
+
+
+
+
+
+ Bypass + Full Blend +
+
+
+ LOUDNESS + +
+ {{ m.label }} + + + + {{ m.value }} +
+
+
+
+
+ HEADPHONES + Connect headphones to enable. +
+ Crossfeed + +
+
+
+
+
+ + +
+
+ +
+
+
Yeh Jeevan Hai Is Jeevan Ka
+
Kishore Kumar
+
+
+ + + +
+ 0:20 +
+
+
+
+ 3:37 + Enhanced· 48 kHz +
+
+
+
+ + + diff --git a/docs/design/now-playing-realigned/html/support.js b/docs/design/now-playing-realigned/html/support.js new file mode 100644 index 0000000..ab2e6b0 --- /dev/null +++ b/docs/design/now-playing-realigned/html/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: