From 160131094cd6dcb2d70aec1444388c4c117b078b Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:20:08 +0530 Subject: [PATCH 01/15] =?UTF-8?q?feat(s10.8=20PR-A):=20zero-risk=20polish?= =?UTF-8?q?=20batch=20=E2=80=94=20deviations=20=C2=A71/=C2=A73/=C2=A74=20q?= =?UTF-8?q?uick=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S10.8 part-1 ladder opens with the audit's no-risk items (docs/sprints/s10-8-deviations-plan.md §B PR-A): - Device pill 252→288: the PR-6 rate slot had squeezed the name area to ~140pt, truncating common device names ("MacBook Pr…") — the audit's one fresh §1 catch. Tabs' fixed x-origin invariant preserved. - Master gain readout SIGNED ("+4.0 dB", 8a) — display + spoken value. - Logo mark: waveform glyph, radius-9 squircle (8a brand mark, was music.note/8). - Headphones section: one line + .help tooltip for the long rationale; disabled-block opacity 0.55 per 8a. - Bypass/Full-Blend captions → new Font.monoMicro (caption2 mono — Dynamic- Type-mapped, never the spec's fixed 9.5pt); labelTertiary stays (nearest audited token to the spec's 32% white — a bespoke pair isn't warranted). - Queue rows: full file-path .help tooltip (AudioFile.id IS the URL). - Format badge chip: fixed 18pt height, radius 9 (8a metrics). 205 tests / 34 suites green; strict-gate PASSED. --- Sources/AdaptiveSound/DesignSystem.swift | 2 ++ Sources/AdaptiveSound/UI/FormatBadgeView.swift | 5 +++-- .../UI/NowPlaying/InspectorColumn.swift | 18 +++++++++++------- .../UI/NowPlaying/MasterGainSliderView.swift | 5 +++-- .../UI/Playlist/PlaylistItemRow.swift | 3 +++ Sources/AdaptiveSound/UI/Shell/ChromeBar.swift | 10 +++++++--- 6 files changed, 29 insertions(+), 14 deletions(-) diff --git a/Sources/AdaptiveSound/DesignSystem.swift b/Sources/AdaptiveSound/DesignSystem.swift index 1df028f..9c96772 100644 --- a/Sources/AdaptiveSound/DesignSystem.swift +++ b/Sources/AdaptiveSound/DesignSystem.swift @@ -118,6 +118,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 diff --git a/Sources/AdaptiveSound/UI/FormatBadgeView.swift b/Sources/AdaptiveSound/UI/FormatBadgeView.swift index 0f33ba3..e28ef23 100644 --- a/Sources/AdaptiveSound/UI/FormatBadgeView.swift +++ b/Sources/AdaptiveSound/UI/FormatBadgeView.swift @@ -9,12 +9,13 @@ struct FormatBadgeView: View { var isSelected: Bool = false var body: some View { + // 8a metrics (deviations §3): fixed 18pt capsule-ish chip, radius 9 (= height/2). Text(format) .font(DesignSystem.Font.micro) .padding(.horizontal, 6) - .padding(.vertical, 3) + .frame(height: 18) .background(isSelected ? Color.asAccent.opacity(0.2) : Color.asCard) .foregroundStyle(isSelected ? Color.asAccent : Color.asLabelSecond) - .clipShape(.rect(cornerRadius: 4, style: .continuous)) + .clipShape(.rect(cornerRadius: 9, style: .continuous)) } } diff --git a/Sources/AdaptiveSound/UI/NowPlaying/InspectorColumn.swift b/Sources/AdaptiveSound/UI/NowPlaying/InspectorColumn.swift index dedc5d8..eb57153 100644 --- a/Sources/AdaptiveSound/UI/NowPlaying/InspectorColumn.swift +++ b/Sources/AdaptiveSound/UI/NowPlaying/InspectorColumn.swift @@ -99,13 +99,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 +134,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 +156,7 @@ struct HeadphonesSectionView: View { } } } - .opacity(isEnabled ? 1 : 0.5) + .opacity(isEnabled ? 1 : 0.55) // 8a disabled-block opacity } } 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..89e66b6 100644 --- a/Sources/AdaptiveSound/UI/Playlist/PlaylistItemRow.swift +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistItemRow.swift @@ -96,6 +96,9 @@ struct PlaylistItemRow: View { } } .contentShape(Rectangle()) + // 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. diff --git a/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift b/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift index cf7ffcc..59bd5ae 100644 --- a/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift +++ b/Sources/AdaptiveSound/UI/Shell/ChromeBar.swift @@ -48,11 +48,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) } @@ -127,7 +129,9 @@ 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) + // 288 (was 252): the PR-6 rate slot squeezed the name area to ~140pt and truncated + // common device names ("MacBook Pr…") — the deviations audit's fresh catch. + .frame(minWidth: 288, maxWidth: 288, 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()) From 31fcf949c8679c329bb5a41208683369342ee915 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:03:33 +0530 Subject: [PATCH 02/15] =?UTF-8?q?docs(s10.8):=20land=20the=20Realigned=20T?= =?UTF-8?q?arget=20package=20=E2=80=94=20the=20visual=20truth=20for=20PR-B?= =?UTF-8?q?..G?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The founder's realignment handoff (PNGs + live HTML mock + REALIGN_GUIDE) moves in at docs/design/now-playing-realigned/. It keeps the deviations ladder A–G and the app's existing layout, and refines every target value on top; where it disagrees with the triage plan, the package wins (deltas annotated in the plan, per the docs rule). Notable reversal recorded: PR-D's mini-equalizer is the package's deterministic sine TimelineView bars, not spectrum-driven. --- docs/design/now-playing-realigned/README.md | 37 + .../now-playing-realigned/REALIGN_GUIDE.md | 253 +++ .../Now Playing - Realigned Target.dc.html | 263 +++ .../now-playing-realigned/html/support.js | 1466 +++++++++++++++++ .../png/00-full-window.png | Bin 0 -> 402080 bytes .../now-playing-realigned/png/01-toolbar.png | Bin 0 -> 71777 bytes .../now-playing-realigned/png/02-badges.png | Bin 0 -> 12221 bytes .../png/03-queue-header.png | Bin 0 -> 22448 bytes .../png/04-playing-row.png | Bin 0 -> 15965 bytes .../png/05-inspector.png | Bin 0 -> 170711 bytes .../png/06-transport.png | Bin 0 -> 88613 bytes docs/sprints/s10-8-deviations-plan.md | 15 +- docs/sprints/sprint-plan.md | 2 +- 13 files changed, 2034 insertions(+), 2 deletions(-) create mode 100644 docs/design/now-playing-realigned/README.md create mode 100644 docs/design/now-playing-realigned/REALIGN_GUIDE.md create mode 100644 docs/design/now-playing-realigned/html/Now Playing - Realigned Target.dc.html create mode 100644 docs/design/now-playing-realigned/html/support.js create mode 100644 docs/design/now-playing-realigned/png/00-full-window.png create mode 100644 docs/design/now-playing-realigned/png/01-toolbar.png create mode 100644 docs/design/now-playing-realigned/png/02-badges.png create mode 100644 docs/design/now-playing-realigned/png/03-queue-header.png create mode 100644 docs/design/now-playing-realigned/png/04-playing-row.png create mode 100644 docs/design/now-playing-realigned/png/05-inspector.png create mode 100644 docs/design/now-playing-realigned/png/06-transport.png 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: