diff --git a/docs/superpowers/plans/2026-04-24-cinematic-liquid-glass-leopard.md b/docs/superpowers/plans/2026-04-24-cinematic-liquid-glass-leopard.md new file mode 100644 index 0000000..c72fed7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-cinematic-liquid-glass-leopard.md @@ -0,0 +1,540 @@ +# Cinematic Liquid Glass Leopard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the SF-32 Cinematic Leopard Hero redesign for the native iOS and macOS SonicFlow surfaces. + +**Architecture:** Keep the implementation low-risk by refactoring existing SwiftUI files instead of adding new Xcode project files. Add small private SwiftUI subviews inside the current root view files, keep all existing audio/session state paths intact, and use token-backed colors plus material/glass-ready surfaces for the new hierarchy. + +**Tech Stack:** SwiftUI, FlowTonesCore, Xcode iOS/macOS targets, generated `BrandTokens`, local `make test-ios` and `make mac` verification. + +--- + +## File Map + +- Modify: `sonicflow_app/ios-app/FlowTones/FlowMode+UI.swift` + - Add mode ritual copy used by the hero. +- Modify: `sonicflow_app/ios-app/FlowTonesTests/FlowModePresentationTests.swift` + - Lock the new ritual labels/summaries. +- Modify: `sonicflow_app/ios-app/FlowTones/LeopardBackgroundView.swift` + - Make the leopard layer more cinematic while preserving text safety through gradients. +- Modify: `sonicflow_app/ios-app/FlowTones/ModeCard.swift` + - Make inactive modes more compact and the active mode more visibly selected. +- Modify: `sonicflow_app/ios-app/FlowTones/ContentView.swift` + - Recompose the iOS screen around hero, visualizer, primary transport, mode selector, and compact controls. +- Modify: `sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowMode+UI.swift` + - Mirror mode ritual copy for macOS. +- Modify: `sonicflow_app/safari-extension/FlowTones/macOS (App)/LeopardBackgroundView.swift` + - Increase cinematic depth in the procedural macOS layer. +- Modify: `sonicflow_app/safari-extension/FlowTones/macOS (App)/ModeCard.swift` + - Make popover mode cards compact and active-state forward. +- Modify: `sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowTonesPopoverView.swift` + - Recompose the popover around the same hero hierarchy at macOS density. + +No new Swift files are planned, which avoids manual `.pbxproj` edits. + +--- + +### Task 1: Add Mode Ritual Presentation Contract + +**Files:** +- Modify: `sonicflow_app/ios-app/FlowTones/FlowMode+UI.swift` +- Modify: `sonicflow_app/ios-app/FlowTonesTests/FlowModePresentationTests.swift` +- Modify: `sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowMode+UI.swift` + +- [ ] **Step 1: Write the failing iOS presentation test** + +Add these assertions to `testFlowModePresentationValuesMatchDesignSpec()` in `sonicflow_app/ios-app/FlowTonesTests/FlowModePresentationTests.swift`: + +```swift +XCTAssertEqual(FlowMode.focus.ritualTitle, "Focus Ritual") +XCTAssertEqual(FlowMode.flow.ritualTitle, "Flow Ritual") +XCTAssertEqual(FlowMode.meditation.ritualTitle, "Meditation Ritual") +XCTAssertEqual(FlowMode.sleep.ritualTitle, "Sleep Ritual") +XCTAssertEqual(FlowMode.focus.ritualSummary, "Gamma clarity for deep work and bright attention.") +XCTAssertEqual(FlowMode.flow.ritualSummary, "Alpha momentum for creative rhythm and smooth concentration.") +XCTAssertEqual(FlowMode.meditation.ritualSummary, "Theta spaciousness for breath, stillness, and recovery.") +XCTAssertEqual(FlowMode.sleep.ritualSummary, "Delta softness for slow unwinding and rest.") +``` + +- [ ] **Step 2: Run the focused failing test** + +Run: + +```sh +make test-ios +``` + +Expected: FAIL because `ritualTitle` and `ritualSummary` do not exist yet. + +- [ ] **Step 3: Add ritual copy to iOS FlowMode presentation** + +Add this extension content in `sonicflow_app/ios-app/FlowTones/FlowMode+UI.swift`: + +```swift +var ritualTitle: String { + switch self { + case .focus: + return "Focus Ritual" + case .flow: + return "Flow Ritual" + case .meditation: + return "Meditation Ritual" + case .sleep: + return "Sleep Ritual" + } +} + +var ritualSummary: String { + switch self { + case .focus: + return "Gamma clarity for deep work and bright attention." + case .flow: + return "Alpha momentum for creative rhythm and smooth concentration." + case .meditation: + return "Theta spaciousness for breath, stillness, and recovery." + case .sleep: + return "Delta softness for slow unwinding and rest." + } +} +``` + +- [ ] **Step 4: Mirror ritual copy for macOS** + +Add the same `ritualTitle` and `ritualSummary` computed properties to `sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowMode+UI.swift`. + +- [ ] **Step 5: Run the focused passing test** + +Run: + +```sh +make test-ios +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```sh +git add sonicflow_app/ios-app/FlowTones/FlowMode+UI.swift sonicflow_app/ios-app/FlowTonesTests/FlowModePresentationTests.swift "sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowMode+UI.swift" +git commit -m "SF-32: add cinematic mode copy" +``` + +--- + +### Task 2: Build The iOS Cinematic Hero Surface + +**Files:** +- Modify: `sonicflow_app/ios-app/FlowTones/LeopardBackgroundView.swift` +- Modify: `sonicflow_app/ios-app/FlowTones/ModeCard.swift` +- Modify: `sonicflow_app/ios-app/FlowTones/ContentView.swift` + +- [ ] **Step 1: Strengthen the iOS leopard background safely** + +Replace the current `LeopardBackgroundView.body` layering with a stronger wallpaper treatment: + +```swift +ZStack { + Image("LeopardWallpaper") + .resizable() + .scaledToFill() + .saturation(1.16) + .contrast(1.08) + .overlay(Color.black.opacity(0.2)) + + LinearGradient( + colors: [ + Color.black.opacity(0.05), + BrandTokens.Neutral.ink.opacity(0.52), + Color.black.opacity(0.82) + ], + startPoint: .top, + endPoint: .bottom + ) + + RadialGradient( + colors: [ + BrandTokens.Accent.gold.opacity(0.26), + Color.clear + ], + center: .topTrailing, + startRadius: 16, + endRadius: 320 + ) +} +``` + +- [ ] **Step 2: Make iOS mode cards compact and active-forward** + +Update `ModeCard` so the button content uses: + +```swift +.frame(maxWidth: .infinity, minHeight: isSelected ? 104 : 84, alignment: .leading) +.padding(BrandTokens.Spacing.md) +.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: BrandTokens.Radius.lg, style: .continuous)) +.overlay( + RoundedRectangle(cornerRadius: BrandTokens.Radius.lg, style: .continuous) + .stroke(isSelected ? mode.accentColor : Color.white.opacity(0.14), lineWidth: isSelected ? 2 : 1) +) +.shadow(color: isSelected ? mode.accentColor.opacity(0.48) : .clear, radius: 30) +``` + +Keep visible labels for `mode.displayName`, beat frequency, and `mode.shortDescription`. + +- [ ] **Step 3: Recompose `ContentView.body` around the hero hierarchy** + +Refactor the `VStack` order in `ContentView.swift` to this hierarchy: + +```swift +hero(for: screenState) + +VisualizerView( + isPlaying: audioManager.isPlaying, + mode: screenState.selectedMode +) + +transportPanel(for: screenState) + +modeSelector(for: screenState) + +sessionPanel(for: screenState) + +starterPack + +Text(screenState.selectedFileLabel) +``` + +Use `.safeAreaPadding(.bottom, BrandTokens.Spacing.lg)` on the scroll content if needed to preserve bottom breathing room. + +- [ ] **Step 4: Replace the iOS header with cinematic hero copy** + +Replace `header(for:)` with a `hero(for:)` helper that renders: + +```swift +Text("SonicFlow") + .font(.caption.weight(.semibold)) + .foregroundStyle(BrandTokens.Accent.gold) + .textCase(.uppercase) + +Text(screenState.selectedMode.ritualTitle) + .font(.system(size: 48, weight: .black, design: .rounded)) + .foregroundStyle(BrandTokens.Neutral.fg) + .minimumScaleFactor(0.72) + +Text(screenState.selectedMode.ritualSummary) + .font(.subheadline) + .foregroundStyle(BrandTokens.Neutral.fg.opacity(0.78)) + +Text("\(Int(screenState.selectedMode.beatHz)) Hz • \(screenState.durationLabel)") + .font(.caption.weight(.semibold)) + .foregroundStyle(screenState.selectedMode.accentColor) +``` + +Place the status chip inside the same hero block: + +```swift +Label(screenState.statusLabel, systemImage: audioManager.isPlaying ? "waveform.circle.fill" : "waveform.circle") + .font(.caption.weight(.semibold)) + .foregroundStyle(audioManager.isPlaying ? BrandTokens.Accent.success : BrandTokens.Neutral.muted) +``` + +Put the hero content on `.ultraThinMaterial` with a large continuous radius and an accent stroke. + +- [ ] **Step 5: Add iOS transport panel** + +Add a private helper in `ContentView.swift`: + +```swift +private func transportPanel(for screenState: FlowScreenState) -> some View { + HStack(spacing: BrandTokens.Spacing.md) { + Button { + audioManager.togglePlayback() + } label: { + Label(screenState.transportLabel, systemImage: audioManager.isPlaying ? "pause.fill" : "play.fill") + .font(.headline.weight(.bold)) + .frame(width: 128, height: 58) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .tint(screenState.selectedMode.accentColor) + + FilePickerButton(playerManager: playerManager) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(BrandTokens.Spacing.md) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 30, style: .continuous)) +} +``` + +- [ ] **Step 6: Add iOS mode selector and compact session panel helpers** + +Extract the existing mode grid into: + +```swift +private func modeSelector(for screenState: FlowScreenState) -> some View { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { + Text("Choose ritual") + .font(.headline) + .foregroundStyle(BrandTokens.Neutral.fg) + + LazyVGrid(columns: columns, spacing: BrandTokens.Spacing.md) { + ForEach(FlowMode.allCases, id: \.self) { mode in + ModeCard(mode: mode, isSelected: mode == screenState.selectedMode) { + audioManager.currentMode = mode + } + } + } + } +} +``` + +Group duration, neural layer, ambient mix, and pulse depth into one material-backed `sessionPanel(for:)`. Keep the same bindings: + +```swift +Binding( + get: { audioManager.sessionSettings.durationMinutes }, + set: audioManager.updateDuration +) +Binding( + get: { audioManager.sessionSettings.ambientMix }, + set: audioManager.updateAmbientMix +) +Binding( + get: { audioManager.sessionSettings.pulseDepth }, + set: audioManager.updatePulseDepth +) +``` + +- [ ] **Step 7: Run iOS tests** + +Run: + +```sh +make test-ios +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```sh +git add sonicflow_app/ios-app/FlowTones/ContentView.swift sonicflow_app/ios-app/FlowTones/LeopardBackgroundView.swift sonicflow_app/ios-app/FlowTones/ModeCard.swift +git commit -m "SF-32: redesign iOS cinematic hero" +``` + +--- + +### Task 3: Build The macOS Popover Parity + +**Files:** +- Modify: `sonicflow_app/safari-extension/FlowTones/macOS (App)/LeopardBackgroundView.swift` +- Modify: `sonicflow_app/safari-extension/FlowTones/macOS (App)/ModeCard.swift` +- Modify: `sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowTonesPopoverView.swift` + +- [ ] **Step 1: Increase macOS procedural leopard depth** + +In `LeopardBackgroundView.swift`, keep deterministic Canvas spots but add a richer overlay after the Canvas: + +```swift +.overlay( + LinearGradient( + colors: [ + Color.black.opacity(0.05), + BrandTokens.Neutral.ink.opacity(0.46), + Color.black.opacity(0.78) + ], + startPoint: .top, + endPoint: .bottom + ) +) +.overlay( + RadialGradient( + colors: [BrandTokens.Accent.gold.opacity(0.2), Color.clear], + center: .topTrailing, + startRadius: 12, + endRadius: 260 + ) +) +``` + +- [ ] **Step 2: Make macOS mode cards popover-dense** + +Update `ModeCard` to use `.thinMaterial`, tighter typography, and active glow: + +```swift +.frame(maxWidth: .infinity, minHeight: isSelected ? 78 : 66, alignment: .leading) +.padding(BrandTokens.Spacing.sm) +.background(.thinMaterial, in: RoundedRectangle(cornerRadius: BrandTokens.Radius.md, style: .continuous)) +.overlay( + RoundedRectangle(cornerRadius: BrandTokens.Radius.md, style: .continuous) + .stroke(isSelected ? mode.accentColor : Color.white.opacity(0.14), lineWidth: isSelected ? 2 : 1) +) +.shadow(color: isSelected ? mode.accentColor.opacity(0.42) : .clear, radius: 24) +``` + +- [ ] **Step 3: Recompose macOS popover root order** + +In `FlowTonesPopoverView.swift`, reorder the main stack to: + +```swift +hero +transportCluster +modeGrid +sessionCluster +starterSessionsPanel +sourceSection +sourceStatus +``` + +Keep the outer `.frame(width: 360, height: 620)`. + +- [ ] **Step 4: Replace macOS hero** + +Replace the current `hero` with: + +```swift +private var hero: some View { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { + HStack { + Text("SonicFlow") + .font(.caption.weight(.semibold)) + .foregroundStyle(BrandTokens.Accent.gold) + .textCase(.uppercase) + Spacer() + Label(audioManager.isPlaying ? "Active" : "Off", systemImage: audioManager.isPlaying ? "waveform.circle.fill" : "waveform.circle") + .font(.caption.weight(.medium)) + .foregroundStyle(audioManager.isPlaying ? BrandTokens.Accent.success : BrandTokens.Neutral.muted) + } + + Text(audioManager.currentMode.ritualTitle) + .font(.system(size: 30, weight: .black, design: .rounded)) + .foregroundStyle(BrandTokens.Neutral.fg) + + Text(audioManager.currentMode.ritualSummary) + .font(.caption) + .foregroundStyle(BrandTokens.Neutral.fg.opacity(0.76)) + + Text("\(Int(audioManager.currentPreset.beatFrequencyHz)) Hz • \(Int(audioManager.currentPreset.carrierFrequencyHz)) Hz • \(audioManager.durationMinutes) min") + .font(.caption.weight(.semibold)) + .foregroundStyle(audioManager.currentMode.accentColor) + } + .padding(BrandTokens.Spacing.md) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: BrandTokens.Radius.lg, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: BrandTokens.Radius.lg, style: .continuous) + .stroke(audioManager.currentMode.accentColor.opacity(0.5), lineWidth: 1) + ) +} +``` + +- [ ] **Step 5: Split macOS controls into transport and session clusters** + +Replace the bottom full-width `Button` and old `controlPanel`/`atmospherePanel` sequence with: + +```swift +private var transportCluster: some View { + HStack(spacing: BrandTokens.Spacing.sm) { + Button { + audioManager.togglePlayback() + } label: { + Label(audioManager.isPlaying ? "Pause" : "Start", systemImage: audioManager.isPlaying ? "pause.fill" : "play.fill") + .font(.headline.weight(.bold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(audioManager.currentMode.accentColor) + + Picker("Source", selection: $audioManager.selectedSource) { + Text("System").tag(AudioSource.system) + Text("File").tag(AudioSource.file) + } + .pickerStyle(.segmented) + .frame(width: 132) + } + .padding(BrandTokens.Spacing.sm) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: BrandTokens.Radius.lg, style: .continuous)) +} +``` + +Create `sessionCluster` by combining the existing duration stepper, beat volume slider, ambient mix slider, and pulse depth slider in one material-backed `VStack`. + +- [ ] **Step 6: Run macOS build** + +Run: + +```sh +make mac +``` + +Expected: BUILD SUCCEEDED. + +- [ ] **Step 7: Commit** + +```sh +git add "sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowTonesPopoverView.swift" "sonicflow_app/safari-extension/FlowTones/macOS (App)/LeopardBackgroundView.swift" "sonicflow_app/safari-extension/FlowTones/macOS (App)/ModeCard.swift" +git commit -m "SF-32: redesign macOS cinematic popover" +``` + +--- + +### Task 4: Final Verification And PR Prep + +**Files:** +- Verify all touched files. + +- [ ] **Step 1: Run formatting/syntax checks** + +Run: + +```sh +git diff --check +``` + +Expected: no output. + +- [ ] **Step 2: Run targeted native gates** + +Run: + +```sh +make test-ios +make mac +``` + +Expected: iOS tests pass and macOS build succeeds. + +- [ ] **Step 3: Run full warning audit if targeted gates pass** + +Run: + +```sh +make verify +``` + +Expected: warning audit passes or reports only pre-accepted toolchain warnings already filtered by `scripts/check_warnings.sh`. + +- [ ] **Step 4: Push branch and open PR** + +Use this PR metadata: + +```sh +git push -u origin feature/SF-32-cinematic-liquid-glass-leopard +gh pr create \ + --base main \ + --head feature/SF-32-cinematic-liquid-glass-leopard \ + --title "[SF-32] Cinematic Liquid Glass Leopard redesign" \ + --body "## Ticket +- SF-32: https://linear.app/captain-leopard-ai-engineering/issue/SF-32/cinematic-liquid-glass-leopard-redesign-for-ios-and-macos + +## Summary +- redesign iOS around a mode-driven Cinematic Leopard Hero +- mirror the native visual hierarchy in the macOS menu-bar popover +- preserve existing audio/session behavior while improving native glass/material hierarchy + +## Verification +- make test-ios +- make mac +- make verify" +``` + +Expected: PR opens in GitHub and uses repo-required ticket title/body format. diff --git a/docs/superpowers/specs/2026-04-24-cinematic-liquid-glass-leopard-design.md b/docs/superpowers/specs/2026-04-24-cinematic-liquid-glass-leopard-design.md new file mode 100644 index 0000000..41ffa26 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-cinematic-liquid-glass-leopard-design.md @@ -0,0 +1,205 @@ +# SF-32 Cinematic Liquid Glass Leopard Design + +## Goal + +Move the native SonicFlow experience from "dark cards on a leopard wallpaper" to a state-of-the-art **Cinematic Leopard Hero** interface for iOS and macOS. + +The redesign should feel more premium, more Apple-native, and more immediately branded while preserving the existing FlowTones session model and audio behavior. + +## Selected Direction + +The chosen direction is **Cinematic Leopard Hero**: + +- The current mode becomes the screen's visual hero. +- Leopard imagery is more visible and emotionally present. +- Text and controls sit on glass or material surfaces, never directly on the leopard texture. +- The primary action is a large Start/Pause control. +- Secondary controls are compact, grouped, and readable. + +This direction intentionally prioritizes a memorable brand impression over a dense control-console layout. + +## Platforms In Scope + +### iOS App + +Update `sonicflow_app/ios-app/FlowTones`: + +- `ContentView.swift` +- `ModeCard.swift` +- `LeopardBackgroundView.swift` +- new small SwiftUI helper views as needed + +### macOS Menu-Bar App + +Update `sonicflow_app/safari-extension/FlowTones/macOS (App)`: + +- `FlowTonesPopoverView.swift` +- `ModeCard.swift` +- `LeopardBackgroundView.swift` +- new small SwiftUI helper views as needed + +Chrome, Safari web-extension shell, and Android are intentionally out of scope for this implementation round. They should inherit the visual language later after the native direction is proven. + +## Product Hierarchy + +### Hero + +The selected mode drives the top-level visual language: + +- Focus: `Focus Ritual` +- Flow: `Flow Ritual` +- Meditation: `Meditation Ritual` +- Sleep: `Sleep Ritual` + +The hero should show: + +- product name `SonicFlow` +- current mode ritual title +- beat frequency and preset metadata +- active/off status +- a short mode-specific sentence + +The legacy `FlowTones` name may remain as small runtime/context metadata, but it should not lead the UI. + +### Primary Action + +Start/Pause becomes the dominant action: + +- iOS: large circular or rounded prominent control near the lower portion of the first screen. +- macOS: compact but still visually primary inside the popover. + +The action should use the current mode accent for state, glow, or tint. + +### Secondary Controls + +Session duration, neural layer, ambient mix, pulse depth, source, and file status should be grouped into compact glass/material clusters. + +The UI should avoid stacking many full-width opaque cards. Controls may remain visible, but the visual hierarchy should make them secondary to the hero and transport action. + +### Mode Selection + +Mode selection should feel like switching rituals, not selecting equal data cards. + +Recommended treatment: + +- The active mode receives the strongest glass/accent treatment. +- Inactive modes are smaller chips or compact cards. +- Existing mode labels, beat frequencies, and short descriptions remain unchanged. + +## Visual System + +### Leopard Layer + +The leopard image or procedural texture is a brand layer, not a content layer. + +Rules: + +- Keep it visible enough to define the product at first glance. +- Add darker gradients where text density increases. +- Do not place body text directly over raw leopard imagery. +- Do not animate the leopard layer. + +### Glass Surfaces + +Use native Liquid Glass APIs where available and material fallbacks where not available. + +Guidelines from Apple docs: + +- Standard SwiftUI controls should be allowed to pick up the newest platform appearance first. +- Custom glass elements should use `glassEffect(_:in:)` where supported. +- Multiple nearby custom glass elements should share a `GlassEffectContainer`. +- Interactive glass should be used only for tappable/focusable elements. +- Earlier OS versions should fall back to `.ultraThinMaterial` or `.thinMaterial` inside the same shapes. + +References: + +- Apple Liquid Glass overview: https://developer.apple.com/documentation/TechnologyOverviews/liquid-glass +- Apple `glassEffect(_:in:)`: https://developer.apple.com/documentation/swiftui/view/glasseffect%28_%3Ain%3A%29 +- Apple `GlassEffectContainer`: https://developer.apple.com/documentation/swiftui/glasseffectcontainer + +### Shape And Density + +- Use large, cinematic radii for hero surfaces. +- Use tighter radii for dense macOS controls. +- Keep buttons and chips stable in size so status text does not resize the layout. +- Keep text readable at small popover widths. + +### Color + +Use existing generated brand tokens: + +- gold for brand identity +- current mode color for state and focus +- success only for active/playing state +- neutral foreground/muted colors for text + +Do not introduce new hex values outside the token source of truth. + +## Architecture + +Introduce only small, local SwiftUI helpers if they reduce duplication: + +- `GlassSurface`: wraps content in native Liquid Glass on new OS versions and material fallback on older versions. +- `HeroModeHeader`: renders product name, ritual title, status, and current-mode metadata. +- `SessionControlPanel`: groups duration, neural layer, and atmosphere controls. +- `ModeSelector`: renders active/inactive mode selection in the new hierarchy. + +These helpers can be duplicated per target if sharing them through a package creates project churn. The implementation should prefer a low-risk native UI refactor over broad build-system changes. + +## Interaction And State + +No audio/session behavior changes are planned. + +The redesign must preserve: + +- mode switching +- starter-session application +- duration updates +- beat volume updates +- ambient mix updates +- pulse depth updates +- play/pause behavior +- file picking and source status +- macOS system-audio capture availability messaging + +## Accessibility + +- Preserve visible text labels for controls. +- Keep sufficient contrast by placing text on glass/material surfaces. +- Use semantic SwiftUI controls instead of purely decorative custom gestures. +- Avoid relying on glow alone to communicate active state. + +## Verification + +Run targeted native checks after implementation: + +```sh +make test-ios +make mac +``` + +If shared verification surfaces are touched or warnings change, run: + +```sh +make verify +``` + +## Out Of Scope + +- Audio engine changes. +- New brand token generation. +- Chrome extension redesign. +- Safari web-extension shell redesign. +- Android Compose redesign. +- New animation systems beyond lightweight SwiftUI transitions or native glass interaction. + +## Acceptance Criteria + +- iOS main screen clearly reads as a Cinematic Leopard Hero experience. +- macOS popover mirrors the same direction without becoming cramped. +- The current mode is the dominant visual context. +- Start/Pause is the dominant action. +- Secondary controls remain discoverable and readable. +- Liquid Glass usage follows Apple's custom-view guidance where available. +- Older OS fallbacks compile and retain the same information hierarchy. +- Existing native tests/build gates pass. diff --git a/sonicflow_app/ios-app/FlowTones/ContentView.swift b/sonicflow_app/ios-app/FlowTones/ContentView.swift index d022059..2abacc0 100644 --- a/sonicflow_app/ios-app/FlowTones/ContentView.swift +++ b/sonicflow_app/ios-app/FlowTones/ContentView.swift @@ -4,6 +4,7 @@ import FlowTonesCore struct ContentView: View { @ObservedObject var audioManager: AudioManager @ObservedObject var playerManager: PlayerManager + private let columns = Array( repeating: GridItem(.flexible(), spacing: BrandTokens.Spacing.md), count: 2 @@ -26,152 +27,235 @@ struct ContentView: View { ScrollView { VStack(alignment: .leading, spacing: BrandTokens.Spacing.lg) { - header(for: screenState) + hero(for: screenState) + + VisualizerView( + isPlaying: audioManager.isPlaying, + mode: screenState.selectedMode + ) + + transportPanel(for: screenState) + + modeSelector(for: screenState) + + sessionPanel(for: screenState) + starterPack - LazyVGrid(columns: columns, spacing: BrandTokens.Spacing.md) { - ForEach(FlowMode.allCases, id: \.self) { mode in - ModeCard( - mode: mode, - isSelected: mode == screenState.selectedMode - ) { - audioManager.currentMode = mode - } - } - } + selectedSourceLabel(for: screenState) + } + .padding(BrandTokens.Spacing.lg) + .padding(.bottom, BrandTokens.Spacing.lg) + } + } + } - VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { - Text("Session") - .font(.headline) - .foregroundStyle(BrandTokens.Neutral.fg) + private func hero(for screenState: FlowScreenState) -> some View { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.md) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { + Text("SonicFlow") + .font(.caption.weight(.semibold)) + .foregroundStyle(BrandTokens.Accent.gold) + .textCase(.uppercase) - HStack { - Text("Duration") - .foregroundStyle(BrandTokens.Neutral.muted) - Spacer() - Stepper( - screenState.durationLabel, - value: Binding( - get: { audioManager.sessionSettings.durationMinutes }, - set: audioManager.updateDuration - ), - in: 5...60, - step: 5 - ) - .tint(screenState.selectedMode.accentColor) - } - } - .padding(BrandTokens.Spacing.md) - .background(BrandTokens.Neutral.panel.opacity(0.9), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) - - VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { - Text("Neural layer") - .font(.headline) - .foregroundStyle(BrandTokens.Neutral.fg) - Slider(value: $audioManager.beatVolume, in: 0...1) - .tint(screenState.selectedMode.accentColor) - } + Text("FlowTones Runtime") + .font(.caption2.weight(.semibold)) + .foregroundStyle(BrandTokens.Neutral.fg.opacity(0.62)) + .textCase(.uppercase) + } - if screenState.showsAdvancedControls { - VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { - Text("Atmosphere") - .font(.headline) - .foregroundStyle(BrandTokens.Neutral.fg) + Spacer() - Text("Ambient mix") - .foregroundStyle(BrandTokens.Neutral.muted) - Slider( - value: Binding( - get: { audioManager.sessionSettings.ambientMix }, - set: audioManager.updateAmbientMix - ), - in: 0.2...1 - ) - .tint(screenState.selectedMode.accentColor) - - Text("Pulse depth") - .foregroundStyle(BrandTokens.Neutral.muted) - Slider( - value: Binding( - get: { audioManager.sessionSettings.pulseDepth }, - set: audioManager.updatePulseDepth - ), - in: 0.2...1 - ) - .tint(screenState.selectedMode.accentColor) - } - .padding(BrandTokens.Spacing.md) - .background(BrandTokens.Neutral.panel.opacity(0.9), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) - } + statusChip(for: screenState) + } - VisualizerView( - isPlaying: audioManager.isPlaying, - mode: screenState.selectedMode - ) + VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { + Text(screenState.selectedMode.ritualTitle) + .font(.system(size: 48, weight: .black, design: .rounded)) + .foregroundStyle(BrandTokens.Neutral.fg) + .lineLimit(2) + .minimumScaleFactor(0.72) - HStack(spacing: BrandTokens.Spacing.md) { - Button(screenState.transportLabel) { - audioManager.togglePlayback() - } - .buttonStyle(.borderedProminent) - .tint(screenState.selectedMode.accentColor) + Text(screenState.selectedMode.ritualSummary) + .font(.subheadline) + .foregroundStyle(BrandTokens.Neutral.fg.opacity(0.78)) + .fixedSize(horizontal: false, vertical: true) + } - FilePickerButton(playerManager: playerManager) - } + ViewThatFits(in: .horizontal) { + HStack(spacing: BrandTokens.Spacing.sm) { + metadataPill("\(Int(screenState.selectedMode.beatHz)) Hz", tint: screenState.selectedMode.accentColor) + metadataPill(screenState.durationLabel, tint: BrandTokens.Accent.gold) + metadataPill(screenState.selectedMode.shortDescription, tint: BrandTokens.Neutral.fg.opacity(0.72)) + } - Text(screenState.selectedFileLabel) - .font(.footnote) - .foregroundStyle(BrandTokens.Neutral.muted) - .lineLimit(1) - .truncationMode(.middle) + VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { + HStack(spacing: BrandTokens.Spacing.sm) { + metadataPill("\(Int(screenState.selectedMode.beatHz)) Hz", tint: screenState.selectedMode.accentColor) + metadataPill(screenState.durationLabel, tint: BrandTokens.Accent.gold) + } + metadataPill(screenState.selectedMode.shortDescription, tint: BrandTokens.Neutral.fg.opacity(0.72)) } - .padding(BrandTokens.Spacing.lg) } } + .padding(BrandTokens.Spacing.lg) + .cinematicGlass( + radius: 34, + tint: screenState.selectedMode.accentColor.opacity(0.16), + stroke: screenState.selectedMode.accentColor.opacity(0.52) + ) + .shadow(color: screenState.selectedMode.accentColor.opacity(0.32), radius: 44, y: 18) + } + + private func statusChip(for screenState: FlowScreenState) -> some View { + Label( + screenState.statusLabel, + systemImage: audioManager.isPlaying ? "waveform.circle.fill" : "waveform.circle" + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(audioManager.isPlaying ? BrandTokens.Accent.success : BrandTokens.Neutral.muted) + .padding(.horizontal, BrandTokens.Spacing.md) + .padding(.vertical, BrandTokens.Spacing.sm) + .cinematicGlass(radius: BrandTokens.Radius.pill, tint: nil, stroke: Color.white.opacity(0.14), interactive: false) } - private func header(for screenState: FlowScreenState) -> some View { - HStack(alignment: .top, spacing: BrandTokens.Spacing.md) { - Image("BowlHero") - .resizable() - .scaledToFill() - .frame(width: 72, height: 72) - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(BrandTokens.Neutral.border, lineWidth: 1) + private func metadataPill(_ text: String, tint: Color) -> some View { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.76) + .padding(.horizontal, BrandTokens.Spacing.md) + .padding(.vertical, BrandTokens.Spacing.sm) + .cinematicGlass(radius: BrandTokens.Radius.pill, tint: tint.opacity(0.12), stroke: tint.opacity(0.24), interactive: false) + } + + private func transportPanel(for screenState: FlowScreenState) -> some View { + HStack(spacing: BrandTokens.Spacing.md) { + Button { + audioManager.togglePlayback() + } label: { + Label( + screenState.transportLabel, + systemImage: audioManager.isPlaying ? "pause.fill" : "play.fill" ) + .font(.headline.weight(.bold)) + .frame(width: 128, height: 58) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .tint(screenState.selectedMode.accentColor) + .shadow(color: screenState.selectedMode.accentColor.opacity(0.42), radius: 28, y: 10) - VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { - Text("FlowTones Runtime") - .font(.caption.weight(.semibold)) - .foregroundStyle(BrandTokens.Accent.gold) - .textCase(.uppercase) + FilePickerButton(playerManager: playerManager) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(BrandTokens.Spacing.md) + .cinematicGlass( + radius: 30, + tint: screenState.selectedMode.accentColor.opacity(0.1), + stroke: Color.white.opacity(0.16) + ) + } - HStack { - Text("SonicFlow") - .font(.largeTitle.weight(.bold)) - .foregroundStyle(BrandTokens.Neutral.fg) - Spacer() - HStack(spacing: BrandTokens.Spacing.sm) { - Circle() - .fill(audioManager.isPlaying ? BrandTokens.Accent.success : BrandTokens.Neutral.muted) - .frame(width: 10, height: 10) - Text(screenState.statusLabel) - .font(.footnote.weight(.medium)) - .foregroundStyle(BrandTokens.Neutral.muted) + private func modeSelector(for screenState: FlowScreenState) -> some View { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { + Text("Choose ritual") + .font(.headline) + .foregroundStyle(BrandTokens.Neutral.fg) + + LazyVGrid(columns: columns, spacing: BrandTokens.Spacing.md) { + ForEach(FlowMode.allCases, id: \.self) { mode in + ModeCard( + mode: mode, + isSelected: mode == screenState.selectedMode + ) { + audioManager.currentMode = mode } - .padding(.horizontal, BrandTokens.Spacing.md) - .padding(.vertical, BrandTokens.Spacing.sm) - .background(BrandTokens.Neutral.panel, in: Capsule()) } + } + } + } - Text("\(screenState.selectedMode.displayName) session with Leopard-backed ambience, pulse shaping, and timed playback.") - .font(.subheadline) + private func sessionPanel(for screenState: FlowScreenState) -> some View { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.md) { + HStack { + Text("Session shaping") + .font(.headline) + .foregroundStyle(BrandTokens.Neutral.fg) + Spacer() + Text("\(Int(screenState.ambientMixValue * 100))% mix") + .font(.caption.weight(.semibold)) + .foregroundStyle(screenState.selectedMode.accentColor) + } + + HStack { + Text("Duration") .foregroundStyle(BrandTokens.Neutral.muted) - .fixedSize(horizontal: false, vertical: true) + Spacer() + Stepper( + screenState.durationLabel, + value: Binding( + get: { audioManager.sessionSettings.durationMinutes }, + set: audioManager.updateDuration + ), + in: 5...60, + step: 5 + ) + .tint(screenState.selectedMode.accentColor) + } + + VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { + controlHeader("Neural layer", value: "\(Int(audioManager.beatVolume * 100))%") + Slider(value: $audioManager.beatVolume, in: 0...1) + .tint(screenState.selectedMode.accentColor) + } + + if screenState.showsAdvancedControls { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { + controlHeader("Ambient mix", value: "\(Int(screenState.ambientMixValue * 100))%") + Slider( + value: Binding( + get: { audioManager.sessionSettings.ambientMix }, + set: audioManager.updateAmbientMix + ), + in: 0.2...1 + ) + .tint(screenState.selectedMode.accentColor) + } + + VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { + controlHeader("Pulse depth", value: "\(Int(screenState.pulseDepthValue * 100))%") + Slider( + value: Binding( + get: { audioManager.sessionSettings.pulseDepth }, + set: audioManager.updatePulseDepth + ), + in: 0.2...1 + ) + .tint(screenState.selectedMode.accentColor) + } } } + .padding(BrandTokens.Spacing.lg) + .cinematicGlass( + radius: 28, + tint: screenState.selectedMode.accentColor.opacity(0.09), + stroke: Color.white.opacity(0.14) + ) + } + + private func controlHeader(_ title: String, value: String) -> some View { + HStack { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(BrandTokens.Neutral.muted) + Spacer() + Text(value) + .font(.caption.weight(.semibold)) + .foregroundStyle(BrandTokens.Neutral.fg) + } } private var starterPack: some View { @@ -184,10 +268,11 @@ struct ContentView: View { Button { audioManager.applyExample(example) } label: { - HStack(alignment: .top, spacing: BrandTokens.Spacing.sm) { + HStack(alignment: .center, spacing: BrandTokens.Spacing.sm) { Image(systemName: example.settings.preset.icon) .foregroundStyle(example.settings.mode.accentColor) - .frame(width: 24) + .frame(width: 28, height: 28) + .background(example.settings.mode.accentColor.opacity(0.16), in: Circle()) VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { Text(example.title) @@ -201,18 +286,57 @@ struct ContentView: View { Spacer() Text("\(example.settings.durationMinutes) min") - .font(.caption.weight(.medium)) + .font(.caption.weight(.bold)) .foregroundStyle(example.settings.mode.accentColor) } .padding(BrandTokens.Spacing.md) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(BrandTokens.Neutral.panel.opacity(0.9)) + .cinematicGlass( + radius: 22, + tint: example.settings.mode.accentColor.opacity(0.08), + stroke: example.settings.mode.accentColor.opacity(0.24) ) } .buttonStyle(.plain) } } } + + private func selectedSourceLabel(for screenState: FlowScreenState) -> some View { + Label(screenState.selectedFileLabel, systemImage: "music.note") + .font(.footnote) + .foregroundStyle(BrandTokens.Neutral.muted) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, BrandTokens.Spacing.md) + } +} + +private extension View { + @ViewBuilder + func cinematicGlass( + radius: CGFloat, + tint: Color?, + stroke: Color, + interactive: Bool = true + ) -> some View { + let shape = RoundedRectangle(cornerRadius: radius, style: .continuous) + + if #available(iOS 26.0, *) { + if interactive { + self + .glassEffect(.regular.tint(tint).interactive(), in: shape) + .overlay(shape.stroke(stroke, lineWidth: 1)) + } else { + self + .glassEffect(.regular.tint(tint), in: shape) + .overlay(shape.stroke(stroke, lineWidth: 1)) + } + } else { + self + .background(.ultraThinMaterial, in: shape) + .overlay(shape.stroke(stroke, lineWidth: 1)) + } + } } diff --git a/sonicflow_app/ios-app/FlowTones/FlowMode+UI.swift b/sonicflow_app/ios-app/FlowTones/FlowMode+UI.swift index 194f975..d99db2b 100644 --- a/sonicflow_app/ios-app/FlowTones/FlowMode+UI.swift +++ b/sonicflow_app/ios-app/FlowTones/FlowMode+UI.swift @@ -28,6 +28,32 @@ extension FlowMode { } } + var ritualTitle: String { + switch self { + case .focus: + return "Focus Ritual" + case .flow: + return "Flow Ritual" + case .meditation: + return "Meditation Ritual" + case .sleep: + return "Sleep Ritual" + } + } + + var ritualSummary: String { + switch self { + case .focus: + return "Gamma clarity for deep work and bright attention." + case .flow: + return "Alpha momentum for creative rhythm and smooth concentration." + case .meditation: + return "Theta spaciousness for breath, stillness, and recovery." + case .sleep: + return "Delta softness for slow unwinding and rest." + } + } + /// Canonical hex identifier for the mode's chakra color. /// /// Kept as a public string for the existing test contract (see diff --git a/sonicflow_app/ios-app/FlowTones/LeopardBackgroundView.swift b/sonicflow_app/ios-app/FlowTones/LeopardBackgroundView.swift index f3e2481..9e72d22 100644 --- a/sonicflow_app/ios-app/FlowTones/LeopardBackgroundView.swift +++ b/sonicflow_app/ios-app/FlowTones/LeopardBackgroundView.swift @@ -6,19 +6,40 @@ struct LeopardBackgroundView: View { Image("LeopardWallpaper") .resizable() .scaledToFill() - .overlay(Color.black.opacity(0.38)) + .saturation(1.16) + .contrast(1.08) + .overlay(Color.black.opacity(0.2)) - Rectangle() - .fill( - LinearGradient( - colors: [ - Color.black.opacity(0.08), - Color.black.opacity(0.5) - ], - startPoint: .top, - endPoint: .bottom - ) - ) + LinearGradient( + colors: [ + Color.black.opacity(0.05), + BrandTokens.Neutral.ink.opacity(0.52), + Color.black.opacity(0.82) + ], + startPoint: .top, + endPoint: .bottom + ) + + RadialGradient( + colors: [ + BrandTokens.Accent.gold.opacity(0.26), + Color.clear + ], + center: .topTrailing, + startRadius: 16, + endRadius: 320 + ) + + RadialGradient( + colors: [ + BrandTokens.Mode.focus.opacity(0.18), + Color.clear + ], + center: .bottomLeading, + startRadius: 12, + endRadius: 280 + ) + .blendMode(.screen) } } } diff --git a/sonicflow_app/ios-app/FlowTones/ModeCard.swift b/sonicflow_app/ios-app/FlowTones/ModeCard.swift index 52194f3..551b8c0 100644 --- a/sonicflow_app/ios-app/FlowTones/ModeCard.swift +++ b/sonicflow_app/ios-app/FlowTones/ModeCard.swift @@ -9,6 +9,18 @@ struct ModeCard: View { var body: some View { Button(action: onTap) { VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs + 2) { + HStack { + Circle() + .fill(mode.accentColor) + .frame(width: 8, height: 8) + Spacer() + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.caption.weight(.bold)) + .foregroundStyle(mode.accentColor) + } + } + Text(mode.displayName) .font(.title3.weight(.semibold)) .foregroundStyle(BrandTokens.Neutral.fg) @@ -20,24 +32,24 @@ struct ModeCard: View { .foregroundStyle(BrandTokens.Neutral.muted) .lineLimit(1) } - .frame(maxWidth: .infinity, minHeight: 94, alignment: .leading) + .frame(maxWidth: .infinity, minHeight: isSelected ? 104 : 84, alignment: .leading) .padding(BrandTokens.Spacing.md) - .background(BrandTokens.Neutral.panel) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: BrandTokens.Radius.lg, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: BrandTokens.Radius.md) + RoundedRectangle(cornerRadius: BrandTokens.Radius.lg, style: .continuous) .stroke( - isSelected ? mode.accentColor : BrandTokens.Neutral.border, + isSelected ? mode.accentColor : Color.white.opacity(0.14), lineWidth: isSelected ? 2 : 1 ) ) - .clipShape(RoundedRectangle(cornerRadius: BrandTokens.Radius.md)) + .clipShape(RoundedRectangle(cornerRadius: BrandTokens.Radius.lg, style: .continuous)) .shadow( - color: isSelected ? mode.accentColor : .clear, - radius: 24 + color: isSelected ? mode.accentColor.opacity(0.48) : .clear, + radius: 30 ) .shadow( - color: isSelected ? mode.accentColor.opacity(0.3) : .clear, - radius: 64 + color: isSelected ? mode.accentColor.opacity(0.24) : .clear, + radius: 72 ) } .buttonStyle(.plain) diff --git a/sonicflow_app/ios-app/FlowTonesTests/FlowModePresentationTests.swift b/sonicflow_app/ios-app/FlowTonesTests/FlowModePresentationTests.swift index 3c021e4..0eac976 100644 --- a/sonicflow_app/ios-app/FlowTonesTests/FlowModePresentationTests.swift +++ b/sonicflow_app/ios-app/FlowTonesTests/FlowModePresentationTests.swift @@ -16,5 +16,13 @@ final class FlowModePresentationTests: XCTestCase { XCTAssertEqual(FlowMode.sleep.displayName, "Sleep") XCTAssertEqual(FlowMode.sleep.accentColorHex, "#534AB7") XCTAssertEqual(FlowMode.sleep.shortDescription, "Delta deep rest") + XCTAssertEqual(FlowMode.focus.ritualTitle, "Focus Ritual") + XCTAssertEqual(FlowMode.flow.ritualTitle, "Flow Ritual") + XCTAssertEqual(FlowMode.meditation.ritualTitle, "Meditation Ritual") + XCTAssertEqual(FlowMode.sleep.ritualTitle, "Sleep Ritual") + XCTAssertEqual(FlowMode.focus.ritualSummary, "Gamma clarity for deep work and bright attention.") + XCTAssertEqual(FlowMode.flow.ritualSummary, "Alpha momentum for creative rhythm and smooth concentration.") + XCTAssertEqual(FlowMode.meditation.ritualSummary, "Theta spaciousness for breath, stillness, and recovery.") + XCTAssertEqual(FlowMode.sleep.ritualSummary, "Delta softness for slow unwinding and rest.") } } diff --git a/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowMode+UI.swift b/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowMode+UI.swift index 7d9c136..56a481b 100644 --- a/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowMode+UI.swift +++ b/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowMode+UI.swift @@ -27,6 +27,32 @@ extension FlowMode { } } + var ritualTitle: String { + switch self { + case .focus: + return "Focus Ritual" + case .flow: + return "Flow Ritual" + case .meditation: + return "Meditation Ritual" + case .sleep: + return "Sleep Ritual" + } + } + + var ritualSummary: String { + switch self { + case .focus: + return "Gamma clarity for deep work and bright attention." + case .flow: + return "Alpha momentum for creative rhythm and smooth concentration." + case .meditation: + return "Theta spaciousness for breath, stillness, and recovery." + case .sleep: + return "Delta softness for slow unwinding and rest." + } + } + var accentColor: Color { switch self { case .focus: diff --git a/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowTonesPopoverView.swift b/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowTonesPopoverView.swift index ce5c560..110b01a 100644 --- a/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowTonesPopoverView.swift +++ b/sonicflow_app/safari-extension/FlowTones/macOS (App)/FlowTonesPopoverView.swift @@ -23,135 +23,164 @@ struct FlowTonesPopoverView: View { VStack(alignment: .leading, spacing: BrandTokens.Spacing.md) { hero - statusStrip + transportCluster - starterSessionsPanel - - LazyVGrid(columns: columns, spacing: BrandTokens.Spacing.sm) { - ForEach(FlowMode.allCases, id: \.self) { mode in - ModeCard(mode: mode, isSelected: mode == audioManager.currentMode) { - audioManager.currentMode = mode - } - } - } - - controlPanel + modeGrid - sourceSection + sessionCluster - atmospherePanel - - sourceStatus + starterSessionsPanel - Button(audioManager.isPlaying ? "Pause" : "Start") { - audioManager.togglePlayback() - } - .buttonStyle(.borderedProminent) - .tint(audioManager.currentMode.accentColor) + sourceCluster } .padding(BrandTokens.Spacing.md) - .background( - RoundedRectangle(cornerRadius: BrandTokens.Radius.lg) - .fill(BrandTokens.Neutral.panel) - .overlay( - RoundedRectangle(cornerRadius: BrandTokens.Radius.lg) - .stroke(BrandTokens.Neutral.border, lineWidth: 1) - ) - ) - .padding(BrandTokens.Spacing.sm) } } .frame(width: 360, height: 620) } private var hero: some View { - HStack(alignment: .top, spacing: BrandTokens.Spacing.sm) { - Image(nsImage: NSApp.applicationIconImage) - .resizable() - .interpolation(.high) - .frame(width: 52, height: 52) - .clipShape(RoundedRectangle(cornerRadius: BrandTokens.Radius.md)) - .overlay( - RoundedRectangle(cornerRadius: BrandTokens.Radius.md) - .stroke(BrandTokens.Neutral.border, lineWidth: 1) - ) - - VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { - Text("FlowTones Runtime") - .font(.caption.weight(.semibold)) - .foregroundStyle(BrandTokens.Accent.gold) - .textCase(.uppercase) - Text("SonicFlow") - .font(.title2.weight(.bold)) - .foregroundStyle(BrandTokens.Neutral.fg) - Text("\(audioManager.currentPreset.displayName) preset with Leopard-backed ambience, starter sessions, and native routing.") - .font(.caption) - .foregroundStyle(BrandTokens.Neutral.muted) + VStack(alignment: .leading, spacing: BrandTokens.Spacing.md) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { + Text("SonicFlow") + .font(.caption.weight(.semibold)) + .foregroundStyle(BrandTokens.Accent.gold) + .textCase(.uppercase) + + Text("FlowTones Runtime") + .font(.caption2.weight(.semibold)) + .foregroundStyle(BrandTokens.Neutral.fg.opacity(0.62)) + .textCase(.uppercase) + } + + Spacer() + + statusChip + } + + HStack(alignment: .center, spacing: BrandTokens.Spacing.md) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .interpolation(.high) + .frame(width: 54, height: 54) + .clipShape(RoundedRectangle(cornerRadius: BrandTokens.Radius.md, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: BrandTokens.Radius.md, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 1) + ) + + VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { + Text(audioManager.currentMode.ritualTitle) + .font(.system(size: 34, weight: .black, design: .rounded)) + .foregroundStyle(BrandTokens.Neutral.fg) + .lineLimit(2) + .minimumScaleFactor(0.76) + + Text(audioManager.currentMode.ritualSummary) + .font(.caption) + .foregroundStyle(BrandTokens.Neutral.fg.opacity(0.78)) + .fixedSize(horizontal: false, vertical: true) + } + } + + ViewThatFits(in: .horizontal) { + HStack(spacing: BrandTokens.Spacing.sm) { + metadataPill("\(Int(audioManager.currentPreset.beatFrequencyHz)) Hz", tint: audioManager.currentMode.accentColor) + metadataPill("\(audioManager.durationMinutes) min", tint: BrandTokens.Accent.gold) + metadataPill(audioManager.currentMode.shortDescription, tint: BrandTokens.Neutral.fg.opacity(0.72)) + } + + VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { + HStack(spacing: BrandTokens.Spacing.sm) { + metadataPill("\(Int(audioManager.currentPreset.beatFrequencyHz)) Hz", tint: audioManager.currentMode.accentColor) + metadataPill("\(audioManager.durationMinutes) min", tint: BrandTokens.Accent.gold) + } + metadataPill(audioManager.currentMode.shortDescription, tint: BrandTokens.Neutral.fg.opacity(0.72)) + } } } + .padding(BrandTokens.Spacing.lg) + .macCinematicGlass( + radius: 30, + tint: audioManager.currentMode.accentColor.opacity(0.14), + stroke: audioManager.currentMode.accentColor.opacity(0.5) + ) + .shadow(color: audioManager.currentMode.accentColor.opacity(0.26), radius: 36, y: 16) } - private var statusStrip: some View { - HStack { - Label(audioManager.statusText, systemImage: audioManager.isPlaying ? "waveform.circle.fill" : "waveform.circle") - .font(.caption) - .foregroundStyle(audioManager.isPlaying ? BrandTokens.Accent.success : BrandTokens.Neutral.muted) - Spacer() - Text("\(Int(audioManager.currentPreset.beatFrequencyHz)) Hz • \(Int(audioManager.currentPreset.carrierFrequencyHz)) Hz") - .font(.caption.weight(.medium)) - .foregroundStyle(audioManager.currentMode.accentColor) - } - .padding(BrandTokens.Spacing.sm) - .background(BrandTokens.Neutral.panel.opacity(0.88), in: RoundedRectangle(cornerRadius: BrandTokens.Radius.md)) + private var statusChip: some View { + Label( + audioManager.isPlaying ? "Active" : "Ready", + systemImage: audioManager.isPlaying ? "waveform.circle.fill" : "waveform.circle" + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(audioManager.isPlaying ? BrandTokens.Accent.success : BrandTokens.Neutral.muted) + .padding(.horizontal, BrandTokens.Spacing.md) + .padding(.vertical, BrandTokens.Spacing.sm) + .macCinematicGlass(radius: BrandTokens.Radius.pill, tint: nil, stroke: Color.white.opacity(0.14), interactive: false) } - private var starterSessionsPanel: some View { - VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { - Text("Starter Sessions") - .font(.caption.weight(.semibold)) - .foregroundStyle(BrandTokens.Neutral.muted) + private func metadataPill(_ text: String, tint: Color) -> some View { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.76) + .padding(.horizontal, BrandTokens.Spacing.md) + .padding(.vertical, BrandTokens.Spacing.sm) + .macCinematicGlass(radius: BrandTokens.Radius.pill, tint: tint.opacity(0.12), stroke: tint.opacity(0.24), interactive: false) + } - LazyVGrid(columns: starterColumns, spacing: BrandTokens.Spacing.sm) { - ForEach(MacFlowToneExample.starterPack) { example in - Button { - audioManager.applyExample(example) - } label: { - HStack(alignment: .top, spacing: BrandTokens.Spacing.sm) { - VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { - Text(example.title) - .font(.headline) - .foregroundStyle(BrandTokens.Neutral.fg) - Text(example.subtitle) - .font(.caption) - .foregroundStyle(BrandTokens.Neutral.muted) - } + private var transportCluster: some View { + HStack(spacing: BrandTokens.Spacing.sm) { + Button { + audioManager.togglePlayback() + } label: { + Label(audioManager.isPlaying ? "Pause" : "Start", systemImage: audioManager.isPlaying ? "pause.fill" : "play.fill") + .font(.headline.weight(.bold)) + .frame(maxWidth: .infinity, minHeight: 46) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.large) + .tint(audioManager.currentMode.accentColor) - Spacer() + Picker("Source", selection: $audioManager.selectedSource) { + Text("System").tag(AudioSource.system) + Text("File").tag(AudioSource.file) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 132) + } + .padding(BrandTokens.Spacing.md) + .macCinematicGlass( + radius: 26, + tint: audioManager.currentMode.accentColor.opacity(0.1), + stroke: Color.white.opacity(0.15) + ) + } - Text("\(example.durationMinutes) min") - .font(.caption.weight(.medium)) - .foregroundStyle(example.preset.mode.accentColor) - } - .padding(BrandTokens.Spacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: BrandTokens.Radius.md) - .fill(BrandTokens.Neutral.panel.opacity(0.9)) - .overlay( - RoundedRectangle(cornerRadius: BrandTokens.Radius.md) - .stroke(example.preset.mode.accentColor.opacity(0.35), lineWidth: 1) - ) - ) + private var modeGrid: some View { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { + Text("Choose ritual") + .font(.headline) + .foregroundStyle(BrandTokens.Neutral.fg) + + LazyVGrid(columns: columns, spacing: BrandTokens.Spacing.sm) { + ForEach(FlowMode.allCases, id: \.self) { mode in + ModeCard(mode: mode, isSelected: mode == audioManager.currentMode) { + audioManager.currentMode = mode } - .buttonStyle(.plain) } } } } - private var controlPanel: some View { - VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { - HStack { + private var sessionCluster: some View { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.md) { + HStack(alignment: .top) { VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { Text(audioManager.currentPreset.displayName) .font(.headline) @@ -159,15 +188,15 @@ struct FlowTonesPopoverView: View { Text(audioManager.currentPreset.summary) .font(.caption) .foregroundStyle(BrandTokens.Neutral.muted) + .fixedSize(horizontal: false, vertical: true) } + Spacer() - } - Picker("Source", selection: $audioManager.selectedSource) { - Text("System audio").tag(AudioSource.system) - Text("File").tag(AudioSource.file) + Text("\(Int(audioManager.currentPreset.carrierFrequencyHz)) Hz carrier") + .font(.caption.weight(.semibold)) + .foregroundStyle(BrandTokens.Neutral.fg.opacity(0.72)) } - .pickerStyle(.segmented) HStack { Text("Duration") @@ -176,79 +205,120 @@ struct FlowTonesPopoverView: View { Spacer() Stepper(value: $audioManager.durationMinutes, in: 5...60, step: 5) { Text("\(audioManager.durationMinutes) min") - .font(.caption) + .font(.caption.weight(.semibold)) .foregroundStyle(BrandTokens.Neutral.fg) } } - VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs + 2) { - Text("Beat volume") + sliderRow( + title: "Beat volume", + valueLabel: "\(Int(audioManager.beatVolume * 100))%", + value: $audioManager.beatVolume, + range: 0...1 + ) + + sliderRow( + title: "Ambient mix", + valueLabel: "\(Int(audioManager.ambientMix * 100))%", + value: $audioManager.ambientMix, + range: 0.2...1 + ) + + sliderRow( + title: "Pulse depth", + valueLabel: "\(Int(audioManager.pulseDepth * 100))%", + value: $audioManager.pulseDepth, + range: 0.2...1 + ) + } + .padding(BrandTokens.Spacing.md) + .macCinematicGlass( + radius: 24, + tint: audioManager.currentMode.accentColor.opacity(0.08), + stroke: Color.white.opacity(0.14) + ) + } + + private func sliderRow( + title: String, + valueLabel: String, + value: Binding, + range: ClosedRange + ) -> some View { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs + 2) { + HStack { + Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(BrandTokens.Neutral.muted) - Slider(value: $audioManager.beatVolume, in: 0...1) - .tint(audioManager.currentMode.accentColor) + Spacer() + Text(valueLabel) + .font(.caption.weight(.semibold)) + .foregroundStyle(BrandTokens.Neutral.fg) } + + Slider(value: value, in: range) + .tint(audioManager.currentMode.accentColor) } - .padding(BrandTokens.Spacing.md) - .background(BrandTokens.Neutral.panel.opacity(0.9), in: RoundedRectangle(cornerRadius: BrandTokens.Radius.md)) } - private var atmospherePanel: some View { + private var starterSessionsPanel: some View { VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { - Text("Atmosphere") - .font(.caption.weight(.semibold)) - .foregroundStyle(BrandTokens.Neutral.muted) + Text("Starter Sessions") + .font(.headline) + .foregroundStyle(BrandTokens.Neutral.fg) - VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs + 2) { - HStack { - Text("Ambient mix") - .font(.caption) - .foregroundStyle(BrandTokens.Neutral.muted) - Spacer() - Text("\(Int(audioManager.ambientMix * 100))%") - .font(.caption) - .foregroundStyle(BrandTokens.Neutral.fg) - } - Slider(value: $audioManager.ambientMix, in: 0.2...1) - .tint(audioManager.currentMode.accentColor) - } + LazyVGrid(columns: starterColumns, spacing: BrandTokens.Spacing.sm) { + ForEach(MacFlowToneExample.starterPack) { example in + Button { + audioManager.applyExample(example) + } label: { + HStack(alignment: .center, spacing: BrandTokens.Spacing.sm) { + Image(systemName: "sparkles") + .foregroundStyle(example.preset.mode.accentColor) + .frame(width: 28, height: 28) + .background(example.preset.mode.accentColor.opacity(0.16), in: Circle()) - VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs + 2) { - HStack { - Text("Pulse depth") - .font(.caption) - .foregroundStyle(BrandTokens.Neutral.muted) - Spacer() - Text("\(Int(audioManager.pulseDepth * 100))%") - .font(.caption) - .foregroundStyle(BrandTokens.Neutral.fg) + VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { + Text(example.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(BrandTokens.Neutral.fg) + Text(example.subtitle) + .font(.caption) + .foregroundStyle(BrandTokens.Neutral.muted) + } + + Spacer() + + Text("\(example.durationMinutes) min") + .font(.caption.weight(.bold)) + .foregroundStyle(example.preset.mode.accentColor) + } + .padding(BrandTokens.Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .macCinematicGlass( + radius: BrandTokens.Radius.lg, + tint: example.preset.mode.accentColor.opacity(0.08), + stroke: example.preset.mode.accentColor.opacity(0.24) + ) + } + .buttonStyle(.plain) } - Slider(value: $audioManager.pulseDepth, in: 0.2...1) - .tint(audioManager.currentMode.accentColor) } } - .padding(BrandTokens.Spacing.md) - .background(BrandTokens.Neutral.panel.opacity(0.9), in: RoundedRectangle(cornerRadius: BrandTokens.Radius.md)) } - private var sourceStatus: some View { - HStack(spacing: BrandTokens.Spacing.sm) { - Image(systemName: audioManager.selectedSource == .system ? "macwindow.on.rectangle" : "music.note") - .foregroundStyle(audioManager.currentMode.accentColor) - Text(sourceStatusText) - .font(.caption) - .foregroundStyle(BrandTokens.Neutral.muted) - .lineLimit(1) - .truncationMode(.middle) - } - } + private var sourceCluster: some View { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { + sourceSection - private var sourceStatusText: String { - if audioManager.selectedSource == .file, let selectedFileURL = playerManager.selectedFileURL { - return selectedFileURL.lastPathComponent + sourceStatus } - - return audioManager.selectedFileLabel + .padding(BrandTokens.Spacing.md) + .macCinematicGlass( + radius: 22, + tint: audioManager.currentMode.accentColor.opacity(0.06), + stroke: Color.white.opacity(0.12) + ) } @ViewBuilder @@ -256,11 +326,11 @@ struct FlowTonesPopoverView: View { switch audioManager.selectedSource { case .system: VStack(alignment: .leading, spacing: BrandTokens.Spacing.sm) { - Text(audioManager.canCaptureSystemAudio ? "System capture verfügbar" : "System capture erst ab macOS 14.2") - .font(.caption2) + Text(audioManager.canCaptureSystemAudio ? "System capture ready" : "System capture needs macOS 14.2") + .font(.caption.weight(.semibold)) .foregroundStyle( audioManager.canCaptureSystemAudio - ? BrandTokens.Neutral.muted + ? BrandTokens.Neutral.fg.opacity(0.74) : BrandTokens.Chakra.sacral ) Text("Permission: \(audioManager.systemAudioPermissionStatus)") @@ -270,6 +340,7 @@ struct FlowTonesPopoverView: View { audioManager.startSystemAudioCapture() } .buttonStyle(.bordered) + .buttonBorderShape(.capsule) .disabled(!audioManager.canCaptureSystemAudio) } case .file: @@ -278,6 +349,7 @@ struct FlowTonesPopoverView: View { playerManager.pickAudioFile() } .buttonStyle(.bordered) + .buttonBorderShape(.capsule) if let selectedFileURL = playerManager.selectedFileURL { Text(selectedFileURL.lastPathComponent) @@ -289,4 +361,52 @@ struct FlowTonesPopoverView: View { } } } + + private var sourceStatus: some View { + HStack(spacing: BrandTokens.Spacing.sm) { + Image(systemName: audioManager.selectedSource == .system ? "macwindow.on.rectangle" : "music.note") + .foregroundStyle(audioManager.currentMode.accentColor) + Text(sourceStatusText) + .font(.caption) + .foregroundStyle(BrandTokens.Neutral.muted) + .lineLimit(1) + .truncationMode(.middle) + } + } + + private var sourceStatusText: String { + if audioManager.selectedSource == .file, let selectedFileURL = playerManager.selectedFileURL { + return selectedFileURL.lastPathComponent + } + + return audioManager.selectedFileLabel + } +} + +private extension View { + @ViewBuilder + func macCinematicGlass( + radius: CGFloat, + tint: Color?, + stroke: Color, + interactive: Bool = true + ) -> some View { + let shape = RoundedRectangle(cornerRadius: radius, style: .continuous) + + if #available(macOS 26.0, *) { + if interactive { + self + .glassEffect(.regular.tint(tint).interactive(), in: shape) + .overlay(shape.stroke(stroke, lineWidth: 1)) + } else { + self + .glassEffect(.regular.tint(tint), in: shape) + .overlay(shape.stroke(stroke, lineWidth: 1)) + } + } else { + self + .background(.thinMaterial, in: shape) + .overlay(shape.stroke(stroke, lineWidth: 1)) + } + } } diff --git a/sonicflow_app/safari-extension/FlowTones/macOS (App)/LeopardBackgroundView.swift b/sonicflow_app/safari-extension/FlowTones/macOS (App)/LeopardBackgroundView.swift index d8a3191..9efa67c 100644 --- a/sonicflow_app/safari-extension/FlowTones/macOS (App)/LeopardBackgroundView.swift +++ b/sonicflow_app/safari-extension/FlowTones/macOS (App)/LeopardBackgroundView.swift @@ -2,29 +2,69 @@ import SwiftUI /// Procedural leopard-print background layer. /// -/// Generated from `BrandTokens.Leopard.*`. Deterministic spot placement — same -/// layout every render, no motion. Always intended to sit BEHIND a blurred panel -/// per brand rules (see `brand/BRAND.md`). +/// Generated from `BrandTokens.Leopard.*`. Deterministic spot placement keeps +/// the popover stable while the gradients preserve text contrast above it. struct LeopardBackgroundView: View { var body: some View { - Canvas { context, size in - let spots = LeopardBackgroundView.spots(in: size) - for spot in spots { - let rect = CGRect( - x: spot.center.x - spot.radius, - y: spot.center.y - spot.radius, - width: spot.radius * 2, - height: spot.radius * 2 - ) - context.fill( - Path(ellipseIn: rect), - with: .color(BrandTokens.Leopard.spot) - ) + ZStack { + Canvas { context, size in + let spots = LeopardBackgroundView.spots(in: size) + for spot in spots { + let ringRect = CGRect( + x: spot.center.x - spot.radius, + y: spot.center.y - spot.radius, + width: spot.radius * 2, + height: spot.radius * 2 + ) + let innerInset = spot.radius * 0.22 + let innerRect = ringRect.insetBy(dx: innerInset, dy: innerInset * 0.72) + + context.fill( + Path(ellipseIn: ringRect), + with: .color(BrandTokens.Leopard.ring.opacity(0.62)) + ) + context.fill( + Path(ellipseIn: innerRect), + with: .color(BrandTokens.Leopard.spot.opacity(0.82)) + ) + } } + .opacity(0.32) + .blur(radius: BrandTokens.Leopard.blurRadius) + .blendMode(.screen) + + LinearGradient( + colors: [ + Color.black.opacity(0.04), + BrandTokens.Neutral.ink.opacity(0.52), + Color.black.opacity(0.84) + ], + startPoint: .top, + endPoint: .bottom + ) + + RadialGradient( + colors: [ + BrandTokens.Accent.gold.opacity(0.3), + Color.clear + ], + center: .topTrailing, + startRadius: 18, + endRadius: 260 + ) + + RadialGradient( + colors: [ + BrandTokens.Mode.focus.opacity(0.18), + Color.clear + ], + center: .bottomLeading, + startRadius: 12, + endRadius: 240 + ) + .blendMode(.screen) } .background(BrandTokens.Leopard.base) - .opacity(BrandTokens.Leopard.opacity) - .blur(radius: BrandTokens.Leopard.blurRadius) } // MARK: - Deterministic spot layout diff --git a/sonicflow_app/safari-extension/FlowTones/macOS (App)/ModeCard.swift b/sonicflow_app/safari-extension/FlowTones/macOS (App)/ModeCard.swift index d95f463..1be21ed 100644 --- a/sonicflow_app/safari-extension/FlowTones/macOS (App)/ModeCard.swift +++ b/sonicflow_app/safari-extension/FlowTones/macOS (App)/ModeCard.swift @@ -7,9 +7,21 @@ struct ModeCard: View { var body: some View { Button(action: onTap) { - VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs) { + VStack(alignment: .leading, spacing: BrandTokens.Spacing.xs + 2) { + HStack { + Circle() + .fill(mode.accentColor) + .frame(width: 7, height: 7) + Spacer() + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.caption.weight(.bold)) + .foregroundStyle(mode.accentColor) + } + } + Text(mode.displayName) - .font(.headline) + .font(.headline.weight(.semibold)) .foregroundStyle(BrandTokens.Neutral.fg) Text("\(Int(mode.beatHz)) Hz") .font(.caption.weight(.medium)) @@ -19,24 +31,24 @@ struct ModeCard: View { .foregroundStyle(BrandTokens.Neutral.muted) .lineLimit(1) } - .frame(maxWidth: .infinity, minHeight: 72, alignment: .leading) + .frame(maxWidth: .infinity, minHeight: isSelected ? 92 : 76, alignment: .leading) .padding(BrandTokens.Spacing.md) - .background(BrandTokens.Neutral.panel) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: BrandTokens.Radius.lg, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: BrandTokens.Radius.md) + RoundedRectangle(cornerRadius: BrandTokens.Radius.lg, style: .continuous) .stroke( - isSelected ? mode.accentColor : BrandTokens.Neutral.border, + isSelected ? mode.accentColor : Color.white.opacity(0.14), lineWidth: isSelected ? 2 : 1 ) ) - .clipShape(RoundedRectangle(cornerRadius: BrandTokens.Radius.md)) + .clipShape(RoundedRectangle(cornerRadius: BrandTokens.Radius.lg, style: .continuous)) .shadow( - color: isSelected ? mode.accentColor : Color.clear, - radius: isSelected ? 24 : 0 + color: isSelected ? mode.accentColor.opacity(0.36) : .clear, + radius: 24 ) .shadow( - color: isSelected ? mode.accentColor.opacity(0.3) : Color.clear, - radius: isSelected ? 64 : 0 + color: isSelected ? mode.accentColor.opacity(0.2) : .clear, + radius: 56 ) } .buttonStyle(.plain)