From ca412ae84a02ce48643fda05cfa0f094f1c0793c Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:11:04 +0530 Subject: [PATCH 01/42] =?UTF-8?q?chore(lint):=20scope=20Swift=20format/lin?= =?UTF-8?q?t=20to=20governed=20source=20=E2=80=94=20docs/=20excluded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design-reference snapshots (docs/design/*/NowPlayingView.swift, from the S10.7 handoff) are artifacts, never compiled — committing one tripped the pre-commit SwiftLint pass. Root fix is scope, not bypass: docs/ is declared out of scope in .swiftlint.yml (excluded), .swiftformat (--exclude), and the hook's staged-file collection (explicit paths don't honor config excludes). --- .githooks/pre-commit | 4 ++++ .swiftformat | 4 ++++ .swiftlint.yml | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 04e595d..555ebe8 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -41,6 +41,10 @@ swift_files=() cpp_files=() while IFS= read -r -d '' f; do [[ -f "$f" ]] || continue + # docs/ holds design-reference snapshots (handoff artifacts, never compiled) — out of + # format/lint/tidy scope. Mirrors .swiftlint.yml `excluded` + .swiftformat `--exclude`; + # the tools need explicit paths here, so the hook must apply the same scope itself. + case "$f" in docs/*) continue ;; esac case "$f" in *.swift) swift_files+=("$f") ;; *.h|*.hpp|*.cpp|*.cc|*.mm) cpp_files+=("$f") ;; diff --git a/.swiftformat b/.swiftformat index f783421..fe0d2de 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,5 +1,9 @@ # SwiftFormat rules for AdaptiveSound +# Design-reference snapshots under docs/ are handoff artifacts, not governed source — +# never reformat them (mirrors .swiftlint.yml `excluded` and the pre-commit hook scope). +--exclude docs + # Keep opening braces on the same line as a multi-line condition (K&R), matching # SwiftLint's `opening_brace` rule. Without this, swiftformat wraps the brace to its # own line on wrapped `if/guard/while` conditions, which swiftlint then flags — the two diff --git a/.swiftlint.yml b/.swiftlint.yml index 53ea10d..2177fe4 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -39,6 +39,10 @@ excluded: - .build/ - .swiftpm/ - Pods/ + # Design-reference snapshots (e.g. docs/design/*/NowPlayingView.swift) are handoff + # artifacts, not governed source — they are never compiled. The pre-commit hook and + # .swiftformat mirror this same scope. + - docs/ # --- Severity promotions (guide §4.2) --- force_cast: error From c56b6e9b5d7c02a56e673bd9b9f16368515e1c1d Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:11:04 +0530 Subject: [PATCH 02/42] docs(s10.7): commit the Liquid Glass design handoff (Now Playing 7a/8a) The founder-approved release design for the Now Playing screen + shell: 8a = 7a Liquid Glass material layer over the 5a layout. INTEGRATION.md maps it onto this repo's governed UI architecture (AppShell/ChromeBar/ NowPlayingBar/DesignSystem) and carries the 6-PR incremental plan the S10.7 sprint is built around. NowPlayingView.swift + the HTML files are visual references only, NOT drop-ins. --- docs/design/now-playing-7a/INTEGRATION.md | 46 + .../now-playing-7a/NowPlayingView.swift | 627 ++++++ .../Player Layout Variants.dc.html | 1684 +++++++++++++++++ docs/design/now-playing-7a/README.md | 63 + .../now-playing-7a/now-playing-base.html | 237 +++ docs/design/now-playing-7a/support.js | 1466 ++++++++++++++ 6 files changed, 4123 insertions(+) create mode 100644 docs/design/now-playing-7a/INTEGRATION.md create mode 100644 docs/design/now-playing-7a/NowPlayingView.swift create mode 100644 docs/design/now-playing-7a/Player Layout Variants.dc.html create mode 100644 docs/design/now-playing-7a/README.md create mode 100644 docs/design/now-playing-7a/now-playing-base.html create mode 100644 docs/design/now-playing-7a/support.js diff --git a/docs/design/now-playing-7a/INTEGRATION.md b/docs/design/now-playing-7a/INTEGRATION.md new file mode 100644 index 0000000..1688e60 --- /dev/null +++ b/docs/design/now-playing-7a/INTEGRATION.md @@ -0,0 +1,46 @@ +# 7a Liquid Glass — Integration guide for `ramith/sound-engineering` + +**Read this BEFORE README.md's generic process.** This guide maps the 7a/8a release design onto the actual codebase (audited @ main, 2026-07-16). The repo has a governed architecture — do NOT drop `NowPlayingView.swift` in as-is; it's a visual reference only. + +## What the codebase already has (don't rebuild) +- **Teal→Lime spectrum**: `UI/Spectrum/SpectrumColorPalette.swift` already implements the exact palette + 0.82 vertical darken; `SpectrumAnalyzerView` already dims to 0.4 when paused, respects Reduce Motion, and runs off real FFT (`AudioViewModel.spectrumBars`, ~20 Hz). ✔ matches design. +- **Shell**: `AppShell` = fixed 60pt `ChromeBar` + bounded content + fixed 64pt footer `NowPlayingBar` (global transport). Native titlebar carries traffic lights. +- **Chrome**: `ChromeBar` = logo squircle + fixed-200pt device pill (`Menu`) + segmented `TabSelectorView`. +- **Now Playing tab**: `NowPlayingTabView` = 50/50 split; `LeftPanelView` (spectrum → master gain → track info, scrollable) + `RightPanelView` → `PlaylistView` (queue). +- **Tokens**: `DesignSystem.swift` — dynamic light/dark colors (WCAG-audited), Dynamic Type fonts, spacing/radius scales, `ShellMetrics`, `Footer` metrics. New code must use these. +- Loudness meters (`UI/Loudness/LoudnessMetersView.swift`), format badges, queue rows with now-playing/selected tints — all exist. + +## Design ↔ code conflicts to resolve (decide before coding) +1. **Transport location.** 7a puts prev/play/next + scrubber in the Now Playing hero; the app deliberately moved transport to the global footer (`NowPlayingBar`, L3 design doc `docs/sprints/l3-footer-transport-design.md`). **Recommendation: keep the footer transport** (it's global across tabs) and adopt 7a's hero as title + badges + analyzer only. Optionally restyle the footer with the glass recipe. +2. **Floating toolbar capsule.** `ChromeBar` is a full-width band under the native titlebar. Making it a detached floating capsule conflicts with the L2 window-drag setup and the "fixed top-left" invariant. **Recommendation: apply the glass material to the existing band** (background + specular bottom hairline) and to the device pill/tab control, not the floating geometry. +3. **Light mode.** 7a's recipes are dark-tuned; the app is dual-appearance via `DesignSystem.Color.dynamic(light:dark:)`. Every new glass fill needs a light variant (e.g. white-based glass `rgba(255,255,255,.55)` + dark hairlines). +4. **The design's 5-tab set** (adds "Monitoring") matches `TabSelection` — ✔ no change. + +## Incremental implementation plan (small, safe PRs) +**PR 1 — Glass tokens.** Extend `DesignSystem` with a `Glass` enum: fills (`glassPanel`, `glassControl`), rim/hairline/bleed shadow values, radii (panel 22, lens 20), and a `.glassPanel()` ViewModifier built on `.ultraThinMaterial` + overlays. Respect `accessibilityReduceTransparency` (fall back to `Color.panel`). No visual change yet. +**PR 2 — Ambient glow.** Add the radial glow ZStack behind `NowPlayingTabView` (teal top-left, lime bottom-right, blue mid-right; dark appearance only, subtler in light). +**PR 3 — Analyzer lens.** Wrap `SpectrumAnalyzerView` in the glass lens: `.glassPanel(radius: 20)` + dB gridlines + 0 dB label + 20 Hz–20 kHz scale + peak-hold caps (new small overlay in the same file/folder; heights still from `spectrumBars`). +**PR 4 — Hero.** Restyle `NowPlayingInfoView` per 8a: 28pt/800 title (map to `DesignSystem.Font.displayTitle` weight override), artist + ENHANCED/format badges (reuse `FormatBadgeView`, add capsule glass variant), pulsing dot gated on Reduce Motion. +**PR 5 — Inspector.** Restyle `RightPanelView`'s siblings: master gain + intensity sliders (5pt carved tracks, 14pt knobs, glow fills), `LoudnessMetersView` bars, disabled crossfeed block at 55% opacity. Keep it in `LeftPanelView`'s scroll (current home) OR move to a 260pt trailing glass column per 8a — flag: that changes `NowPlayingTabView`'s 50/50 `containerRelativeFrame` split to queue-flex + fixed-260. +**PR 6 — Chrome + footer glass.** Apply glass materials to `ChromeBar` band, device pill, tab control, and `NowPlayingBar`. Keep all `ShellMetrics`/`Footer` metrics unchanged. + +Each PR: `make run` visual check in BOTH appearances + Reduce Transparency/Motion, and the repo's strict CI (`scripts/strict-gate.sh`, swiftlint) must pass. The `.claude/agents/swiftui-pro.md` + `.claude/skills/macos-design/` agents in the repo are the right reviewers to invoke. + +## Prompt to give Claude Code (VS Code) +> Implement the "7a Liquid Glass" release design per docs/design/now-playing-7a/INTEGRATION.md, following its 6-PR plan. Start with PR 1 (DesignSystem.Glass tokens). Visual reference: now-playing-base.html + the 8a card in Player Layout Variants.dc.html. Do not move the transport out of NowPlayingBar; do not change AppShell band structure. All colors via DesignSystem tokens with light+dark variants; respect Reduce Transparency and Reduce Motion. + +## File map (design element → code home) +| Design (8a) | Code | +|---|---| +| Ambient glows | `NowPlayingTabView` background ZStack (new) | +| Toolbar glass | `UI/Shell/ChromeBar.swift` (+`DesignSystem.Glass`) | +| Device pill + 44.1 kHz readout | `ChromeBar.DevicePillView` (rate from `AudioEngineBridge+Devices`) | +| Hero title/badges | `UI/NowPlaying/NowPlayingInfoView.swift` | +| Analyzer lens + grid + peaks | `UI/Spectrum/SpectrumAnalyzerView.swift` (+ small overlays) | +| Spectrum palette | ✔ already `SpectrumColorPalette.swift` | +| Master gain slider | `UI/NowPlaying/MasterGainSliderView.swift` | +| Intensity slider | left panel (bridge: `AudioEngineBridge+IntensityControl`) | +| Loudness meters | `UI/Loudness/LoudnessMetersView.swift` | +| Crossfeed toggle | bridge: `AudioEngineBridge+CrossfeedControl` | +| Queue rows/tooltips | `UI/Playlist/PlaylistView.swift` + row views (tints: `rowNowPlaying`/`rowSelected`) | +| Transport | stays in `UI/Shell/NowPlayingBar.swift` (glass restyle only) | diff --git a/docs/design/now-playing-7a/NowPlayingView.swift b/docs/design/now-playing-7a/NowPlayingView.swift new file mode 100644 index 0000000..da8e1ce --- /dev/null +++ b/docs/design/now-playing-7a/NowPlayingView.swift @@ -0,0 +1,627 @@ +// +// NowPlayingView.swift +// AdaptiveSound — "Now Playing" screen, design variant 5a ("Glass Inspector") +// +// Reference SwiftUI implementation of the handoff design. +// Wire the sample state to your real audio engine / DSP core. +// Requires macOS 13+. +// + +import SwiftUI + +// MARK: - Design tokens + +enum DS { + // Teal accent set + static let teal = Color(hex: 0x29B6A4) + static let tealLight = Color(hex: 0x3FD0BA) + static let tealLighter = Color(hex: 0x4FD2C0) + static let tealText = Color(hex: 0x6FE0D0) + static let tealBright = Color(hex: 0x8AF0E0) + static let tealDeep = Color(hex: 0x1FA893) + static let tealDarkest = Color(hex: 0x14897A) + static let onTeal = Color(hex: 0x0C1413) + static let amber = Color(hex: 0xFFB347) + + static let windowBG = Color(hex: 0x17181B) + + /// Spectrum palette: teal → lime, mapped to horizontal position (low → high freq) + static let spectrumStops: [(t: Double, color: NSColor)] = [ + (0.0, NSColor(hex: 0x1F9D8B)), (0.2, NSColor(hex: 0x36C1AB)), + (0.4, NSColor(hex: 0x4FD2C0)), (0.6, NSColor(hex: 0x7FE3A8)), + (0.8, NSColor(hex: 0xA8EC84)), (1.0, NSColor(hex: 0xC8F06A)), + ] + + static func spectrumColor(at t: Double) -> Color { + let stops = spectrumStops + var i = 0 + while i < stops.count - 2 && t > stops[i + 1].t { + i += 1 + } + let (t0, c0) = stops[i], (t1, c1) = stops[i + 1] + let k = (t - t0) / (t1 - t0) + return Color(nsColor: c0.lerp(to: c1, k: k)) + } +} + +// MARK: - Model + +struct Track: Identifiable { + let id = UUID() + let title: String + let duration: String + let format: String + let absolutePath: String +} + +final class PlayerModel: ObservableObject { + @Published var tracks: [Track] = Track.samples + @Published var currentIndex = 1 + @Published var isPlaying = true + @Published var elapsed: Double = 114 // seconds + @Published var duration: Double = 278 + @Published var masterGainDB: Double = 4.0 // −12…+12 + @Published var intensity: Double = 0.20 // 0…1 + @Published var integratedLUFS: Double = -15.1 + @Published var shortTermLUFS: Double = -14.2 + @Published var truePeakDBTP: Double = -0.8 + @Published var crossfeedEnabled = false + @Published var headphonesConnected = false + /// Log-bucketed FFT magnitudes 0…1, low → high frequency. Feed from the audio engine. + @Published var spectrum: [Double] = (0 ..< 72).map { _ in .random(in: 0.15 ... 0.95) } + + var currentTrack: Track { + tracks[currentIndex] + } + + func togglePlay() { + isPlaying.toggle() + } + + func previous() { + currentIndex = (currentIndex - 1 + tracks.count) % tracks.count; isPlaying = true + } + + func next() { + currentIndex = (currentIndex + 1) % tracks.count; isPlaying = true + } + + func play(_ track: Track) { + if let i = tracks.firstIndex(where: { $0.id == track.id }) { currentIndex = i; isPlaying = true } + } +} + +// MARK: - Root view + +struct NowPlayingView: View { + @StateObject private var model = PlayerModel() + + var body: some View { + VStack(spacing: 0) { + ToolbarView() + HeroBand(model: model) + HStack(spacing: 0) { + QueueColumn(model: model) + InspectorPanel(model: model) + .frame(width: 260) + .padding(.trailing, 14) + .padding(.vertical, 12) + } + .frame(maxHeight: .infinity) + } + .background(DS.windowBG) + .frame(minWidth: 1120, minHeight: 720) + } +} + +// MARK: - Toolbar + +struct ToolbarView: View { + @State private var selectedTab = "Now Playing" + private let tabs = ["Now Playing", "Library", "EQ", "Monitoring", "Settings"] + + var body: some View { + HStack(spacing: 14) { + // App mark + RoundedRectangle(cornerRadius: 8) + .fill(LinearGradient(colors: [DS.tealLight, DS.tealDarkest], + startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 26, height: 26) + .overlay(Image(systemName: "waveform").font(.system(size: 12, weight: .bold)).foregroundStyle(.white)) + + // Output device capsule (single source of device selection) + Menu { + Button("MacBook Pro Speakers") {} + Button("External Headphones") {} + } label: { + HStack(spacing: 8) { + Image(systemName: "speaker.wave.2").font(.system(size: 11)).foregroundStyle(DS.tealLighter) + Text("MacBook Pro Speakers").font(.system(size: 12.5, weight: .semibold)) + Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold)).opacity(0.5) + } + .padding(.horizontal, 13).frame(height: 32) + .background(.white.opacity(0.08), in: Capsule()) + } + .buttonStyle(.plain) + + // Tab capsule segmented control + HStack(spacing: 2) { + ForEach(tabs, id: \.self) { tab in + Button { selectedTab = tab } label: { + Text(tab) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(selectedTab == tab ? DS.onTeal : .white.opacity(0.65)) + .padding(.horizontal, 13).frame(height: 26) + .background(selectedTab == tab ? DS.teal.opacity(0.92) : .clear, in: Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(3) + .background(.black.opacity(0.35), in: Capsule()) + + Spacer() + } + .padding(.horizontal, 16).frame(height: 52) + .background(.ultraThinMaterial) + .overlay(alignment: .bottom) { Divider().opacity(0.4) } + } +} + +// MARK: - Hero band + +struct HeroBand: View { + @ObservedObject var model: PlayerModel + + var body: some View { + HStack(alignment: .top, spacing: 20) { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 7) { + Text(model.currentTrack.title) + .font(.system(size: 28, weight: .heavy)).kerning(-0.4) + .foregroundStyle(.white).lineLimit(1) + HStack(spacing: 8) { + Text("Nirvana").font(.system(size: 13.5, weight: .medium)).foregroundStyle(.white.opacity(0.7)) + BadgeView(text: "ENHANCED \(Int(model.intensity * 100))%", tint: .teal) + BadgeView(text: "\(model.currentTrack.format) · 44.1 kHz", tint: .gray) + } + } + TransportRow(model: model) + } + .frame(maxWidth: .infinity, alignment: .leading) + + SpectrumAnalyzer(model: model) + .frame(width: 400, height: 122) + } + .padding(EdgeInsets(top: 20, leading: 22, bottom: 18, trailing: 22)) + .background(LinearGradient(colors: [DS.teal.opacity(0.08), .clear], startPoint: .top, endPoint: .bottom)) + .overlay(alignment: .bottom) { Divider().opacity(0.4) } + } +} + +struct BadgeView: View { + enum Tint { case teal, gray } + let text: String + let tint: Tint + + var body: some View { + Text(text) + .font(.system(size: 9.5, weight: .bold)).kerning(0.5) + .foregroundStyle(tint == .teal ? DS.tealText : .white.opacity(0.6)) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(tint == .teal ? DS.teal.opacity(0.18) : .white.opacity(0.08), + in: RoundedRectangle(cornerRadius: 7)) + } +} + +struct TransportRow: View { + @ObservedObject var model: PlayerModel + + var body: some View { + HStack(spacing: 14) { + // Glass transport pill + HStack(spacing: 4) { + TransportButton(system: "backward.fill", size: 38) { model.previous() } + Button(action: model.togglePlay) { + Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 17, weight: .bold)).foregroundStyle(.white) + .frame(width: 48, height: 48) + .background( + LinearGradient(colors: [DS.tealLight, DS.tealDeep, DS.tealDarkest], + startPoint: .topLeading, endPoint: .bottomTrailing), + in: Circle() + ) + .shadow(color: DS.tealDeep.opacity(0.8), radius: 10, y: 6) + } + .buttonStyle(.plain) + TransportButton(system: "forward.fill", size: 38) { model.next() } + } + .padding(5) + .background(.white.opacity(0.06), in: Capsule()) + + Text(timeString(model.elapsed)).monoTime() + ScrubberSlider(value: Binding( + get: { model.elapsed / model.duration }, + set: { model.elapsed = $0 * model.duration } + )) + Text(timeString(model.duration)).monoTime() + } + } + + private func timeString(_ s: Double) -> String { + "\(Int(s) / 60):" + String(format: "%02d", Int(s) % 60) + } +} + +struct TransportButton: View { + let system: String + let size: CGFloat + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + Image(systemName: system) + .font(.system(size: 13, weight: .bold)).foregroundStyle(.white.opacity(0.9)) + .frame(width: size, height: size) + .background(hovering ? .white.opacity(0.08) : .clear, in: Circle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + } +} + +/// 4px slim slider with a 13px white knob — used by scrubber, gain, intensity. +struct ScrubberSlider: View { + @Binding var value: Double // 0…1 + var fillColor: Color = DS.tealLight + + var body: some View { + GeometryReader { geo in + let w = geo.size.width + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.14)).frame(height: 4) + Capsule() + .fill(LinearGradient(colors: [DS.tealDeep, fillColor], startPoint: .leading, endPoint: .trailing)) + .frame(width: max(0, w * value), height: 4) + Circle().fill(.white).frame(width: 13, height: 13) + .shadow(color: .black.opacity(0.55), radius: 3, y: 1) + .offset(x: w * value - 6.5) + } + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0).onChanged { g in + value = min(1, max(0, g.location.x / w)) + }) + } + .frame(height: 14) + } +} + +extension Text { + func monoTime() -> some View { + font(.system(size: 11, design: .monospaced)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.55)) + } +} + +// MARK: - Spectrum analyzer (teal → lime, boxed panel) + +struct SpectrumAnalyzer: View { + @ObservedObject var model: PlayerModel + + var body: some View { + ZStack(alignment: .bottom) { + RoundedRectangle(cornerRadius: 16).fill(.black.opacity(0.34)) + + // Bars — replace the animation with real FFT updates from the engine. + HStack(alignment: .bottom, spacing: 2) { + ForEach(model.spectrum.indices, id: \.self) { i in + let t = Double(i) / Double(model.spectrum.count - 1) + let base = DS.spectrumColor(at: t) + UnevenRoundedRectangle(topLeadingRadius: 1.5, topTrailingRadius: 1.5) + .fill(LinearGradient(colors: [base, base.darkened(0.18)], + startPoint: .top, endPoint: .bottom)) + .frame(maxWidth: .infinity) + .frame(height: nil) + .scaleEffect(y: model.isPlaying ? model.spectrum[i] : model.spectrum[i] * 0.4, + anchor: .bottom) + } + } + .padding(EdgeInsets(top: 10, leading: 12, bottom: 16, trailing: 12)) + .opacity(model.isPlaying ? 1 : 0.4) + .animation(.easeOut(duration: 0.12), value: model.spectrum) + + // Frequency scale + HStack { + Text("20 Hz"); Spacer(); Text("200"); Spacer(); Text("2 k"); Spacer(); Text("20 kHz") + } + .font(.system(size: 8.5, design: .monospaced)) + .foregroundStyle(.white.opacity(0.35)) + .padding(.horizontal, 12).padding(.bottom, 4) + } + .overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(.white.opacity(0.05))) + } +} + +// MARK: - Queue + +struct QueueColumn: View { + @ObservedObject var model: PlayerModel + @State private var filter = "" + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Text("Queue").font(.system(size: 14, weight: .bold)).foregroundStyle(.white.opacity(0.94)) + Text("\(model.tracks.count) tracks · 12:07:44") + .font(.system(size: 11, design: .monospaced)).monospacedDigit() + .foregroundStyle(.white.opacity(0.38)) + Spacer() + HStack(spacing: 7) { + Image(systemName: "magnifyingglass").font(.system(size: 10)) + TextField("Filter queue", text: $filter) + .textFieldStyle(.plain).font(.system(size: 11.5)).frame(width: 90) + } + .foregroundStyle(.white.opacity(0.42)) + .padding(.horizontal, 12).frame(height: 28) + .background(.white.opacity(0.07), in: Capsule()) + } + .padding(EdgeInsets(top: 14, leading: 22, bottom: 10, trailing: 16)) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(model.tracks.enumerated()), id: \.element.id) { i, track in + QueueRow(track: track, index: i, + isActive: i == model.currentIndex, + isPlaying: model.isPlaying) { model.play(track) } + } + } + .padding(EdgeInsets(top: 0, leading: 14, bottom: 12, trailing: 8)) + } + } + .frame(maxWidth: .infinity) + } +} + +struct QueueRow: View { + let track: Track + let index: Int + let isActive: Bool + let isPlaying: Bool + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 11) { + Group { + if isActive { + MiniEqualizer(animating: isPlaying) + } else { + Text("\(index + 1)") + .font(.system(size: 11, design: .monospaced)).monospacedDigit() + .foregroundStyle(.white.opacity(0.32)) + } + } + .frame(width: 18, alignment: .trailing) + + Text(track.title) + .font(.system(size: 13, weight: isActive ? .semibold : .regular)) + .foregroundStyle(isActive ? DS.tealBright : .white.opacity(0.82)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(track.format) + .font(.system(size: 9.5, weight: .bold)) + .foregroundStyle(isActive ? DS.tealText : .white.opacity(0.45)) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(isActive ? DS.teal.opacity(0.18) : .white.opacity(0.06), + in: RoundedRectangle(cornerRadius: 4)) + + Text(track.duration) + .font(.system(size: 11, design: .monospaced)).monospacedDigit() + .foregroundStyle(.white.opacity(0.45)) + .frame(width: 44, alignment: .trailing) + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 9) + .fill(isActive ? DS.teal.opacity(0.16) : hovering ? .white.opacity(0.05) : .clear) + ) + .overlay(RoundedRectangle(cornerRadius: 9) + .strokeBorder(isActive ? DS.teal.opacity(0.4) : .clear)) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help(track.absolutePath) // tooltip: full file path + } +} + +struct MiniEqualizer: View { + let animating: Bool + @State private var phase = false + + var body: some View { + HStack(alignment: .bottom, spacing: 2) { + ForEach(0 ..< 3, id: \.self) { i in + RoundedRectangle(cornerRadius: 1) + .fill(DS.tealLighter) + .frame(width: 2.5, height: phase ? 12 : 5) + .animation(.easeInOut(duration: 0.6 + Double(i) * 0.18) + .repeatForever(autoreverses: true).delay(Double(i) * 0.12), + value: phase) + } + } + .frame(height: 12) + .onAppear { phase = animating } + .onChange(of: animating) { phase = $0 } + } +} + +// MARK: - Inspector (floating glass, in flow) + +struct InspectorPanel: View { + @ObservedObject var model: PlayerModel + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + // Master gain + VStack(alignment: .leading, spacing: 8) { + InspectorLabel(title: "Master Gain", value: String(format: "%+.1f dB", model.masterGainDB)) + ScrubberSlider(value: Binding( + get: { (model.masterGainDB + 12) / 24 }, + set: { model.masterGainDB = $0 * 24 - 12 } + )) + } + // Intensity + VStack(alignment: .leading, spacing: 8) { + InspectorLabel(title: "Intensity", value: "\(Int(model.intensity * 100)) %") + ScrubberSlider(value: $model.intensity) + } + Divider().overlay(.white.opacity(0.09)) + + // Loudness meters + VStack(alignment: .leading, spacing: 9) { + Text("Loudness").inspectorTitle() + LoudnessMeter(label: "Integrated", value: model.integratedLUFS, range: -30 ... 0) + LoudnessMeter(label: "Short-term", value: model.shortTermLUFS, range: -30 ... 0) + LoudnessMeter(label: "True peak", value: model.truePeakDBTP, range: -12 ... 0, hot: model.truePeakDBTP > -1) + } + Divider().overlay(.white.opacity(0.09)) + + // Crossfeed + HStack { + Text("Crossfeed").font(.system(size: 12.5, weight: .medium)).foregroundStyle(.white.opacity(0.78)) + Spacer() + Toggle("", isOn: $model.crossfeedEnabled) + .toggleStyle(.switch).labelsHidden().controlSize(.small) + .disabled(!model.headphonesConnected) + } + if !model.headphonesConnected { + Text("Connect headphones to enable.") + .font(.system(size: 10.5)).foregroundStyle(.white.opacity(0.42)) + } + Spacer() + } + .padding(EdgeInsets(top: 18, leading: 16, bottom: 18, trailing: 16)) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18)) + .background(Color(hex: 0x1A1C20).opacity(0.66), in: RoundedRectangle(cornerRadius: 18)) + .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(.white.opacity(0.05))) + .shadow(color: .black.opacity(0.65), radius: 20, y: 8) + } +} + +struct InspectorLabel: View { + let title: String + let value: String + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title).inspectorTitle() + Spacer() + Text(value) + .font(.system(size: 11.5, design: .monospaced)).monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + } + } +} + +extension Text { + func inspectorTitle() -> some View { + font(.system(size: 11, weight: .bold)).kerning(0.4) + .textCase(.uppercase).foregroundStyle(.white.opacity(0.6)) + } +} + +struct LoudnessMeter: View { + let label: String + let value: Double + let range: ClosedRange + var hot = false + + private var fraction: Double { + (value - range.lowerBound) / (range.upperBound - range.lowerBound) + } + + var body: some View { + HStack(spacing: 10) { + Text(label).font(.system(size: 11.5)).foregroundStyle(.white.opacity(0.72)) + .frame(width: 62, alignment: .leading) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.1)) + Capsule() + .fill(LinearGradient( + colors: hot ? [DS.tealDeep, DS.tealLighter, DS.amber] : [DS.tealDeep, DS.tealLighter], + startPoint: .leading, endPoint: .trailing + )) + .frame(width: geo.size.width * max(0, min(1, fraction))) + } + } + .frame(height: 6) + Text(String(format: "%.1f", value)) + .font(.system(size: 10, design: .monospaced)).monospacedDigit() + .foregroundStyle(hot ? DS.amber : .white.opacity(0.85)) + .frame(width: 38, alignment: .trailing) + } + } +} + +// MARK: - Sample data & helpers + +extension Track { + static let samples: [Track] = [ + .init(title: "Bruno Mars, Adele, Ed Sheeran, Maroon 5, Dua Lipa — Billboard Top 50 This Week", duration: "174:12", format: "MP3", absolutePath: "~/Music/Library/Playlists/Billboard Top 50.mp3"), + .init(title: "Nirvana — Smells Like Teen Spirit (Official Music Video)", duration: "4:38", format: "MP3", absolutePath: "~/Music/Library/Rock/Nirvana/Smells Like Teen Spirit.mp3"), + .init(title: "Premasiri Kemadasa — Master Songs Collection", duration: "37:31", format: "MP3", absolutePath: "~/Music/Library/Classical/Kemadasa/Master Songs.mp3"), + .init(title: "Muse — Starlight (Official Music Video)", duration: "4:04", format: "MP3", absolutePath: "~/Music/Library/Rock/Muse/Starlight.mp3"), + .init(title: "Green Day — Boulevard Of Broken Dreams [4K Upgrade]", duration: "4:47", format: "MP3", absolutePath: "~/Music/Library/Rock/Green Day/Boulevard.mp3"), + .init(title: "Gotye — Somebody That I Used To Know (feat. Kimbra)", duration: "4:03", format: "MP3", absolutePath: "~/Music/Library/Pop/Gotye/Somebody.mp3"), + .init(title: "PSY — Gangnam Style (M/V)", duration: "4:12", format: "MP3", absolutePath: "~/Music/Library/Pop/PSY/Gangnam Style.mp3"), + .init(title: "Ed Sheeran, Rihanna, Selena Gomez — Billboard Hot 100", duration: "203:16", format: "MP3", absolutePath: "~/Music/Library/Playlists/Billboard Hot 100.mp3"), + .init(title: "LMFAO — Sexy and I Know It", duration: "3:23", format: "MP3", absolutePath: "~/Music/Library/Pop/LMFAO/Sexy and I Know It.mp3"), + .init(title: "Beautiful South — Perfect 10", duration: "3:35", format: "MP3", absolutePath: "~/Music/Library/Pop/Beautiful South/Perfect 10.mp3"), + .init(title: "Ed Sheeran — You Need Me, I Don't Need You", duration: "4:01", format: "MP3", absolutePath: "~/Music/Library/Pop/Ed Sheeran/You Need Me.mp3"), + .init(title: "Kasabian — Where Did All the Love Go (Video)", duration: "4:10", format: "MP3", absolutePath: "~/Music/Library/Rock/Kasabian/Where Did All the Love Go.mp3"), + .init(title: "Vanessa Carlton — A Thousand Miles", duration: "4:26", format: "MP3", absolutePath: "~/Music/Library/Pop/Vanessa Carlton/A Thousand Miles.mp3"), + .init(title: "Nickelback — Rockstar", duration: "4:15", format: "MP3", absolutePath: "~/Music/Library/Rock/Nickelback/Rockstar.mp3"), + .init(title: "Red Hot Chili Peppers — Scar Tissue [HD Upgrade]", duration: "3:41", format: "MP3", absolutePath: "~/Music/Library/Rock/RHCP/Scar Tissue.mp3"), + ] +} + +extension Color { + init(hex: UInt32) { + self.init(red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255) + } + + /// Darken by multiplying RGB (matches the prototype's 18% darkening). + func darkened(_ amount: Double) -> Color { + let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black + return Color(red: ns.redComponent * (1 - amount), + green: ns.greenComponent * (1 - amount), + blue: ns.blueComponent * (1 - amount)) + } +} + +extension NSColor { + convenience init(hex: UInt32) { + self.init(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, alpha: 1) + } + + func lerp(to other: NSColor, k: CGFloat) -> NSColor { + let a = usingColorSpace(.sRGB)!, b = other.usingColorSpace(.sRGB)! + return NSColor(srgbRed: a.redComponent + (b.redComponent - a.redComponent) * k, + green: a.greenComponent + (b.greenComponent - a.greenComponent) * k, + blue: a.blueComponent + (b.blueComponent - a.blueComponent) * k, + alpha: 1) + } +} + +#Preview { + NowPlayingView() +} diff --git a/docs/design/now-playing-7a/Player Layout Variants.dc.html b/docs/design/now-playing-7a/Player Layout Variants.dc.html new file mode 100644 index 0000000..4c157f7 --- /dev/null +++ b/docs/design/now-playing-7a/Player Layout Variants.dc.html @@ -0,0 +1,1684 @@ + + + + + + + + + + + + + + +
+
8Production pass on 7a — instrument-grade analyzer, resolved hierarchy, finished states
+
+ + +
+
8aProduction — analyzer gains gridlines + peak caps + 0 dB label, device pill gains sample rate, hero/queue separation resolved with a fading hairline, disabled crossfeed reads as disabled, unified badge radii.
+
+ +
+
+
+ + +
+
+ + + +
+
+
MacBook Pro Speakers 44.1 kHz
+
+ Now Playing + Library + EQ + Monitoring + Settings +
+
+ + +
+
+
+
Smells Like Teen Spirit
+
+ Nirvana + + ENHANCED · 20% + MP3 · 44.1 kHz +
+
+
+
+ + + +
+ 1:54 +
+ 4:38 +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
0 dB
+
20 Hz2002 k20 kHz
+
+
+ + +
+ + +
+ +
+
+ Queue + 41 tracks · 12:07:44 +
+
Filter queue
+
+
+ +
+ {{ q.lead }} + {{ q.title }} + MP3 + {{ q.dur }} +
+
+
+
+ +
+
+
+
Master Gain+4.0 dB
+
+
+
+
Intensity20 %
+
+
+
+
+
Loudness
+
+
Integrated
−15.1
+
Short-term
−14.2
+
True peak
−0.8
+
+
+
+
+
+ Crossfeed + +
+ Connect headphones to enable. +
+
+
+
+
+
+
+ +
+

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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