From 52ff56ffc2bead24c0a41daf110d6db8faf24cba Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:29:43 -0500 Subject: [PATCH] feat(settings): deep links into Settings panes (WW-53) wolfwave://settings/[/
] opens Settings at a named pane, scrolls to the tagged card, and flashes it. Stable kebab-case slugs (never display titles), safe fallback to General for anything unknown. Replaces the Twitch-only selectedSettingsSection UserDefaults hint, which also fixes navigation when the window is already open. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + CLAUDE.md | 5 +- README.md | 1 + apps/docs/content/docs/changelog.mdx | 1 + apps/docs/content/docs/settings.mdx | 63 ++++++++++++- .../WolfWave/Core/AppConstants+Discord.swift | 3 - .../WolfWave/Core/AppConstants+Twitch.swift | 3 - .../Core/AppConstants+UserDefaults.swift | 5 - apps/native/WolfWave/Core/AppConstants.swift | 4 + .../WolfWave/Core/AppDelegate+MenuBar.swift | 3 +- .../WolfWave/Core/AppDelegate+Services.swift | 3 +- .../WolfWave/Core/AppDelegate+Windows.swift | 19 ++++ .../WolfWave/Core/NotificationPayloads.swift | 4 +- apps/native/WolfWave/Core/Preferences.swift | 10 -- .../WolfWave/Core/SettingsDeepLink.swift | 71 ++++++++++++++ .../WolfWave/Core/SettingsNavigation.swift | 29 ++++++ apps/native/WolfWave/Info.plist | 13 +++ .../Views/Advanced/AdvancedSettingsView.swift | 4 + .../Views/Discord/DiscordSettingsView.swift | 4 + .../WolfWave/Views/GeneralSettingsView.swift | 4 + .../HistoryStatsSettingsView.swift | 4 + apps/native/WolfWave/Views/SettingsView.swift | 87 +++++++++++++++-- .../Views/Shared/DeepLinkAnchor.swift | 54 +++++++++++ .../SoftwareUpdateSettingsView.swift | 1 + .../SongRequest/SongRequestSettingsView.swift | 8 ++ .../StreamDeck/StreamDeckSettingsView.swift | 3 + .../Views/Twitch/TwitchSettingsView.swift | 2 + .../WebSocket/WebSocketSettingsView.swift | 4 + .../WolfWaveTests/AppConstantsTests.swift | 3 - .../WolfWaveTests/SettingsDeepLinkTests.swift | 94 +++++++++++++++++++ design-system/components/README.md | 1 + design-system/components/deep-link-anchor.md | 54 +++++++++++ 32 files changed, 522 insertions(+), 43 deletions(-) create mode 100644 apps/native/WolfWave/Core/SettingsDeepLink.swift create mode 100644 apps/native/WolfWave/Core/SettingsNavigation.swift create mode 100644 apps/native/WolfWave/Views/Shared/DeepLinkAnchor.swift create mode 100644 apps/native/WolfWaveTests/SettingsDeepLinkTests.swift create mode 100644 design-system/components/deep-link-anchor.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c54a97b5..660b306e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. ### Added - **Custom commands can pick how they answer.** Each custom command now has a "How it's sent" setting: reply to the chatter (the default, and what existing commands keep doing), a plain chat message, or a Twitch announcement. Announcements need the signed-in account to be a mod and a one-time Twitch reconnect to grant the new permission; if Twitch refuses, the command falls back to a reply and the Custom Commands card tells you why. +- **Links that open Settings right where you need them.** WolfWave now answers `wolfwave://settings/` and `wolfwave://settings//
` links, so the docs (or your own notes) can open the Twitch pane, scroll to Custom Commands, and flash it so you can't miss it. Works whether Settings is closed or already open on another pane. A link to something that doesn't exist just opens General. The full list of pane and section names is on the [Settings](https://mrdemonwolf.github.io/wolfwave/docs/settings#deep-links) page. - **Five new Stream Deck keys.** **Announce Song** posts what's playing to your chat, same wording as `!song`. **Reject Request** drops the request that's playing and tells chat it went, so the requester isn't left wondering where their song came from and vanished (Skip just moves on silently). **Block Requester** bars the person who requested the current song, held for a second so you can't do it by accident. **Request Access** cycles who can request (Everyone, Subscribers, VIPs & Subs, Mods) and shows the current setting right on the key. **Now Playing** shows the track over its album art and does nothing when pressed. - **You can block people from requesting, not just songs.** Settings → Song Requests → Blocklist now takes a Twitch username as well as song titles and artists. A blocked person's requests are turned away before WolfWave even looks the song up. - **A Sponsor link you can turn off.** WolfWave now has a Sponsor item in the menu bar's Help submenu as well as the Help menu and the About pane. If you'd rather not be asked, turn off "Show sponsor links" in Settings, About. The toggle stays put so you can turn it back on, and WolfWave is free and complete either way. diff --git a/CLAUDE.md b/CLAUDE.md index 5e4a42ae3..4907909bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,7 +214,7 @@ The crash-class lint gate is **blocking** on production source: `.swiftlint-cras ### Source layout (`apps/native/WolfWave/`) -- **Core/** - `AppConstants.swift` split across per-namespace `extension` files: `AppConstants+Notifications.swift`, `AppConstants+Discord.swift`, `AppConstants+Twitch.swift`, `AppConstants+URLs.swift`, `AppConstants+UserDefaults.swift` (centralized config enums for keys, identifiers, timing, notification names), the `AppDelegate+*` extensions, `KeychainService.swift` (macOS Security framework wrapper; Twitch access token, refresh token, resolved identity, and configured channel share one crash-atomic versioned record with copy-then-delete migration from legacy fields), `LogTailCursor.swift` (incremental log tailing: bounded priming, partial-line carry, re-prime on truncate/rotate), `Logger.swift` (structured logging; `LogCategory` is the ONLY accepted category type, there is no `String` overload, so a typo is a compile error; logfmt-style line ` [ key=value…]`, one invariant: a record starts with an ISO-8601 timestamp at column 0 and a line starting with whitespace is a continuation. Grammar + redaction rules in `apps/native/docs/logging-format.md`), `LogRecord.swift` (the pure, canonical **reader** for that format, used by the Debug log viewer, the diagnostics export, and external tooling. Do not hand-roll a second parser), `PowerStateMonitor.swift`, `NetworkInfoService.swift` (LAN IP cache), `StreamerMode.swift` (UI-only masking of sensitive values for on-camera safety; observable singleton read across settings views and the menu bar), `SongRequestItem.swift`, `BlocklistItem.swift`. Foundation utilities: `HTTPClient.swift` (shared async HTTP wrapper), `JSONCoders.swift` (shared `JSONEncoder`/`JSONDecoder`), `BugReportURL.swift` (pre-filled GitHub issue URL builder), `Bundle+InstallMethod.swift` (DMG vs Homebrew install detection), `Preferences.swift` / `FeatureFlags.swift` (typed `UserDefaults` accessors; strings/ints and bool toggles, so reads route through one place instead of scattered defaults calls), `DefaultsStore.swift` (the single `UserDefaults` instance every non-`@AppStorage` read and write goes through; resolves to an isolated suite under test so the hosted bundle can never edit the dev app's live domain, mirroring `KeychainService.backend`), `SharedFormatters.swift` / `ByteFormatting.swift` / `StringFormatting.swift` (shared date, byte, and string-truncation formatting), `ThreadSafeStorage.swift` (`Atomic`, the `nonisolated @unchecked Sendable` + `NSLock` box used by actor→sync bridge seams like `DiscordRPCService.stateSnapshot` and the Twitch dispatcher flags). Also in Core/: `AppContainer.swift` (Application Support / temp directory resolution), `AppearanceController.swift` (app-wide `NSApp.appearance` override), `KeychainBackend.swift` (test-injectable storage behind `KeychainService`), `DiagnosticsService.swift` (opt-in MetricKit diagnostics + share card), `MetricsService.swift`, `MenuStatusFormatter.swift`, `ExternalLink.swift`, `Pasteboard.swift`, `ImageEncoding.swift`, `InlineMarkdown.swift`, `NotificationPayloads.swift`, `RecentTrack.swift`, `SponsorConfig.generated.swift`, `CrashReporter.swift` (process-wide last-gasp crash handlers; the marker now carries kind/signal/pid/version/build/epoch, pre-baked at install so the signal path stays async-signal-safe), `CrashMarker.swift` (parses that marker, plus the legacy shapes; read at launch and written into the log BEFORE the marker is cleared), `DiagnosticSnapshot.swift` (one environment block for the bug report, the export header, and the Debug card. NOT `#if DEBUG` on purpose), `DiagnosticsBundle.swift` (composes the export: environment + crash + every rotated log, oldest first), `MusicProcess.swift` (resolves the running Music.app by pid so ScriptingBridge never relaunches a quit Music; see PR #392), `UITestMode.swift` (the out-of-process test seam: `isUnderTestHarness` is what `DefaultsStore` and `KeychainService` branch on, so a UI test gets throwaway storage the same way the hosted unit bundle does), and the `Core/ListeningHistory/` subdir (`PlayLogStore`, `PlayRecord`, `LifetimeTally`, `DurationSanitizer` (clamps implausible track durations), `HistoryStoreSupport` (shared store filesystem + day-bucketing helpers)). +- **Core/** - `AppConstants.swift` split across per-namespace `extension` files: `AppConstants+Notifications.swift`, `AppConstants+Discord.swift`, `AppConstants+Twitch.swift`, `AppConstants+URLs.swift`, `AppConstants+UserDefaults.swift` (centralized config enums for keys, identifiers, timing, notification names), the `AppDelegate+*` extensions, `KeychainService.swift` (macOS Security framework wrapper; Twitch access token, refresh token, resolved identity, and configured channel share one crash-atomic versioned record with copy-then-delete migration from legacy fields), `LogTailCursor.swift` (incremental log tailing: bounded priming, partial-line carry, re-prime on truncate/rotate), `Logger.swift` (structured logging; `LogCategory` is the ONLY accepted category type, there is no `String` overload, so a typo is a compile error; logfmt-style line ` [ key=value…]`, one invariant: a record starts with an ISO-8601 timestamp at column 0 and a line starting with whitespace is a continuation. Grammar + redaction rules in `apps/native/docs/logging-format.md`), `LogRecord.swift` (the pure, canonical **reader** for that format, used by the Debug log viewer, the diagnostics export, and external tooling. Do not hand-roll a second parser), `PowerStateMonitor.swift`, `NetworkInfoService.swift` (LAN IP cache), `StreamerMode.swift` (UI-only masking of sensitive values for on-camera safety; observable singleton read across settings views and the menu bar), `SongRequestItem.swift`, `BlocklistItem.swift`. Foundation utilities: `HTTPClient.swift` (shared async HTTP wrapper), `JSONCoders.swift` (shared `JSONEncoder`/`JSONDecoder`), `BugReportURL.swift` (pre-filled GitHub issue URL builder), `Bundle+InstallMethod.swift` (DMG vs Homebrew install detection), `Preferences.swift` / `FeatureFlags.swift` (typed `UserDefaults` accessors; strings/ints and bool toggles, so reads route through one place instead of scattered defaults calls), `DefaultsStore.swift` (the single `UserDefaults` instance every non-`@AppStorage` read and write goes through; resolves to an isolated suite under test so the hosted bundle can never edit the dev app's live domain, mirroring `KeychainService.backend`), `SharedFormatters.swift` / `ByteFormatting.swift` / `StringFormatting.swift` (shared date, byte, and string-truncation formatting), `ThreadSafeStorage.swift` (`Atomic`, the `nonisolated @unchecked Sendable` + `NSLock` box used by actor→sync bridge seams like `DiscordRPCService.stateSnapshot` and the Twitch dispatcher flags). Also in Core/: `AppContainer.swift` (Application Support / temp directory resolution), `AppearanceController.swift` (app-wide `NSApp.appearance` override), `KeychainBackend.swift` (test-injectable storage behind `KeychainService`), `DiagnosticsService.swift` (opt-in MetricKit diagnostics + share card), `MetricsService.swift`, `MenuStatusFormatter.swift`, `ExternalLink.swift`, `Pasteboard.swift`, `ImageEncoding.swift`, `InlineMarkdown.swift`, `NotificationPayloads.swift`, `RecentTrack.swift`, `SponsorConfig.generated.swift`, `CrashReporter.swift` (process-wide last-gasp crash handlers; the marker now carries kind/signal/pid/version/build/epoch, pre-baked at install so the signal path stays async-signal-safe), `CrashMarker.swift` (parses that marker, plus the legacy shapes; read at launch and written into the log BEFORE the marker is cleared), `DiagnosticSnapshot.swift` (one environment block for the bug report, the export header, and the Debug card. NOT `#if DEBUG` on purpose), `DiagnosticsBundle.swift` (composes the export: environment + crash + every rotated log, oldest first), `MusicProcess.swift` (resolves the running Music.app by pid so ScriptingBridge never relaunches a quit Music; see PR #392), `SettingsDeepLink.swift` (pure `wolfwave://settings/[/
]` parser; never fails, falls back to General) + `SettingsNavigation.swift` (`@MainActor @Observable` hand-off: `AppDelegate.navigateSettings(to:)` sets `pending`, `SettingsView` consumes it, so a link works with the window already open; replaced the old `selectedSettingsSection` UserDefaults hint), `UITestMode.swift` (the out-of-process test seam: `isUnderTestHarness` is what `DefaultsStore` and `KeychainService` branch on, so a UI test gets throwaway storage the same way the hosted unit bundle does), and the `Core/ListeningHistory/` subdir (`PlayLogStore`, `PlayRecord`, `LifetimeTally`, `DurationSanitizer` (clamps implausible track durations), `HistoryStoreSupport` (shared store filesystem + day-bucketing helpers)). - **Monitors/** - Apple Music playback monitoring. `AppleMusicSource.swift` uses PID-targeted ScriptingBridge with a retained error delegate, distributed notifications, monotonic event deduplication, and 5s fallback polling throttled slower in low-power mode, and reports updates through `PlaybackSourceDelegate.swift`. - **Services/Twitch/** - `TwitchChatService.swift` (actor-isolated EventSub WebSocket + Helix chat API, network path monitoring for reconnection, Twitch user ID redacted in logs; also dispatches `channel.channel_points_custom_reward_redemption.add` and qualifying `channel.bits.use` cheer events into the song-request pipeline), `TwitchChannelPointsService.swift` (Helix create / reconcile / fulfill / cancel for the WolfWave-managed "Request a Song" reward), `TwitchRedemptionResolutionOutbox.swift` (atomic disk-backed paid-event state: channel-point intake becomes a known fulfill/refund before Helix delivery, unknown startup intake refunds conservatively, and complete Bits boost/request actions replay until atomically acknowledged), `HelixClient.swift` (shared Helix API wrapper over `HTTPClient`: auth headers, body encode, status validation, Helix error mapping; used by `TwitchChatService` and `TwitchChannelPointsService`), `TwitchDeviceAuth.swift` (OAuth Device Code flow). The chat-service actor is split across same-actor seam files: `TwitchChatService+Auth.swift`, `TwitchChatService+Connection.swift`, `TwitchChatService+EventSub.swift`, `TwitchChatService+Redemptions.swift`. - **Services/Twitch/Commands/** - `BotCommand` protocol (`triggers`, `description`, `execute(message:) -> String?`), `AsyncBotCommand` for I/O-bound commands, `BotCommandContext`, `BotCommandDispatcher`. Concrete commands: `TrackInfoCommand` (drives `!song`, `!last`, and `!stats` via three configured instances), `InfoCommand` (`!wolfwave`, static reply styled by `WolfWaveReplyStyle`), `SongRequestCommand`, `QueueCommand`, `MyQueueCommand`, `SkipCommand`, `HoldCommand`, `ClearQueueCommand`, `VoteSkipCommand` (chat vote-to-skip), `SongListCommand` (`!playlist`). Streamer-authored commands run through `CustomBotCommand` (an `AsyncBotCommand` rebuilt per message from `CustomCommandStore`), backed by the `CustomCommand` model and the `CustomCommandRenderer` enum, which does variable substitution (`$user`/`$sender`, `$touser`, `$args`, `$1`–`$9`, `$song`, `$lastsong`) and `CommandPermission` gating (everyone/subscriber/vip/moderator/broadcaster). Each `CustomCommand` also carries a `ReplyDelivery` (reply / message / announce, decoded as `.reply` when absent from older storage); the dispatcher returns it in `CommandReply` and `TwitchChatService+EventSub` routes announce through `sendAnnouncement` (Helix `/chat/announcements`, never the `sendMessageOnce` 401→reauth path) with a reply fallback and an `AnnounceStatus` banner key. `StatsCommandFormat` supplies the `!stats` window formatting. `CooldownManager` enforces global + per-user cooldowns. @@ -229,12 +229,13 @@ The crash-class lint gate is **blocking** on production source: `.swiftlint-cras - **Views/** - SwiftUI settings shell `SettingsView.swift` with `NavigationSplitView` sidebar. Per-section views decomposed into `GeneralSettingsView.swift`, `MusicMonitor/MusicMonitorSettingsView.swift`, `AppVisibility/AppVisibilitySettingsView.swift`, `WebSocket/WebSocketSettingsView.swift` (Stream Widgets: connection, browser source, appearance, then the raw feed) + `WebSocket/WebSocketCustomOverlayCard.swift` (port, overlay token, and the two WebSocket addresses) + `WebSocket/WebSocketTokenEditorRow.swift` (the one credential editor both panes render, so overlay and control validation cannot drift), `StreamDeck/StreamDeckSettingsView.swift` (Stream Deck: the `streamDeckControlEnabled` capability switch, the control token, setup steps) + `StreamDeck/StreamDeckPaneStatus.swift` (the pure `nonisolated` header-chip resolver; the shared server outranks the command switch, because commands on a stopped server produce a key that silently does nothing), `Twitch/TwitchSettingsView.swift`, `Discord/DiscordSettingsView.swift`, `SongRequest/SongRequestSettingsView.swift` + `SongRequestQueueView.swift`, `Notifications/NotificationsSettingsView.swift`, `HistoryStats/HistoryStatsSettingsView.swift` + `StatsChartsView.swift` + `MonthlyWrapView.swift` (SwiftUI Charts powered, gated on the opt-in Listening History setting), `Appearance/AppearanceSettingsView.swift`, `SoftwareUpdate/SoftwareUpdateSettingsView.swift`, `About/AboutSettingsView.swift` + `AboutCopy.swift` (pure copy strings incl. the displayed copyright year), `Advanced/AdvancedSettingsView.swift` + `SettingsImportSheet.swift` + `DiagnosticsShareCardView.swift`. The shell itself is split into `SettingsSidebarView.swift` (sidebar) and `SettingsSceneBridge.swift` (AppKit window plumbing). Per-pane supporting views: `Discord/DiscordPreviewCard.swift` + `DiscordButtonConfigRow.swift`, `MusicMonitor/MusicPermissionState.swift` + `PermissionDeniedView.swift`, `Twitch/TwitchCommandsCard.swift` + `CustomCommandsCard.swift` + `DeviceCodeView.swift`, `WebSocket/WidgetAppearancePreview.swift`, and `SongRequest/Setup/SongRequestSetupView.swift` + `SongRequestSetupViewModel.swift` (the guided setup gate). `TwitchViewModel` is the main observable for auth/connection state. - **Views/Onboarding/** - macOS 26 Liquid Glass onboarding wizard. The `OnboardingStep` enum (in `OnboardingViewModel.swift`) defines the step order: Welcome → Discord → Twitch → OBS Widget (overlay URL + HTTP widget toggle) → Preferences → Permissions (Apple Music automation only) → Notifications (notification authorization + the song-change / skip-vote alert toggles) → Menu Bar Pointer, followed by `OnboardingCompletionView`. Permissions and Notifications are deliberately two separate screens so each has a single job. One file per step: `OnboardingWelcomeStepView`, `OnboardingDiscordStepView`, `OnboardingTwitchStepView`, `OnboardingOBSWidgetStepView`, `OnboardingPreferencesStepView`, `OnboardingPermissionsStepView`, `OnboardingNotificationsStepView`, `OnboardingMenuBarPointerStepView`, all hosted by the `OnboardingView.swift` wizard container. Components in `Onboarding/Components/` (`PillButton`, `BrandTile`, `OnboardingStepScaffold`, `OnboardingToggleCard`, `WolfHeroMark`). - **Views/Debug/** - **DEBUG-only** developer tooling tab. `DebugSettingsView.swift` shell plus cards: `DebugInspectorsCard`, `DebugLogViewerCard` (live tail of the log file: level/category filters, search, follow toggle, backed by `Core/LogTailCursor.swift` for incremental reads and `Core/LogRecord.swift` for parsing), `DebugLogsAndEventsCard`, `DebugMetricsCard`, `DebugServiceControlsCard`, `DebugUIPreviewsCard` (the What's New / onboarding / simulated-update triggers), over the `DebugDiagnostics.swift` snapshot helpers (Connections vs Preferences are separate tables on purpose: a preference being on is not evidence a service connected). `Debug/DesignSystem/` holds the design-system gallery: `DebugComponentGalleryCard` (+ one `ComponentGallery+.swift` extension per group) renders every `Views/Shared/` view in the states its `#Preview` blocks declare, and `DebugTokenGalleryCard` renders every token family by iterating the generated `DSColor.groups` / `DSFont.Size.all` / `DSSpace.all` / `DSRadius.all` / `DSMotion.Duration.all` / `DSMotion.Spring.all` / `DSDimension.groups` lists, so it cannot drift from `tokens.json`; `MotionGallerySection` (the contentTransition / symbolEffect / TimelineView / AsyncImage demos) lives there too. Rail layout lives in `DebugSection.railGroups` and is coverage-checked by `DebugSectionCoverageTests`. Not compiled into release builds. -- **Views/Shared/** - Shared UI components: `StatusChip`, `InfoRow`, `ToggleSettingRow`, `SuccessFeedbackRow`, `SectionHeaderWithStatus`, `CardEyebrowHeader`, `NowPlayingHeroCard`, `AlbumArtView`, `IntegrationDashboardView`, `CalloutBanner` (consolidated info/success/warning/error/neutral tinted callout that replaced the old `WarningBanner` / `ConfigRequiredBanner` / `ConnectionTestButton` variants), `Binding+Sanitized` (`snapped(to:fallback:)` / `clamped(to:fallback:)`; wrap any `@AppStorage`-backed `Picker` selection or `Slider` value with these, because a persisted value outside the control's tags or bounds traps inside SwiftUI and takes the settings window down), `CopyButton`, `CopyableURLRow`, `OpenInBrowserButton`, `SharePickerButton`, `DestructiveButton`, `DSIconButton`, `AsyncActionButton` (one button owning one `async` action: spinner replaces the label, control disabled while in flight, brief success checkmark, width pinned by `stableWidth` so no phase change resizes the row. Not for a button that fire-and-forgets a `Task`, and not usable inside `.alert` / `.confirmationDialog`, which only accept plain `Button`s), `UpdateBannerView`, `WhatsNewView`, `ActionGrid`, `LoadingRow`, `HintRow`, `MusicPermissionBanner`, `StatTile`, `LabeledSlider`, `CooldownSliderPair`, `CommandAliasField`, `CommandSettingRow`, `ResponsiveRow`, `QRCodeImage`, `StreamerModeBadge`, `TwitchConnectionNotice`, `TwitchGlitchShape`, `ViewModifiers`, `SettingsNavRail` (shared two-column jump-nav rail + scroll-sync used by General, Debug, Song Requests, and History & Stats; sections conform `SettingsRailSection` and tag their top view with `.railSection(_:)`; panes that use it bypass `standardDetailScroll` in `SettingsView.detailPane` to own the full pane width). Sensitive fields wrap their value in a `StreamerMode.shared` check before rendering; when Streamer Mode is on, the value is replaced with a `••••••` mask and Copy/Open buttons are disabled. +- **Views/Shared/** - Shared UI components: `StatusChip`, `InfoRow`, `ToggleSettingRow`, `SuccessFeedbackRow`, `SectionHeaderWithStatus`, `CardEyebrowHeader`, `NowPlayingHeroCard`, `AlbumArtView`, `IntegrationDashboardView`, `CalloutBanner` (consolidated info/success/warning/error/neutral tinted callout that replaced the old `WarningBanner` / `ConfigRequiredBanner` / `ConnectionTestButton` variants), `Binding+Sanitized` (`snapped(to:fallback:)` / `clamped(to:fallback:)`; wrap any `@AppStorage`-backed `Picker` selection or `Slider` value with these, because a persisted value outside the control's tags or bounds traps inside SwiftUI and takes the settings window down), `CopyButton`, `CopyableURLRow`, `OpenInBrowserButton`, `SharePickerButton`, `DestructiveButton`, `DSIconButton`, `AsyncActionButton` (one button owning one `async` action: spinner replaces the label, control disabled while in flight, brief success checkmark, width pinned by `stableWidth` so no phase change resizes the row. Not for a button that fire-and-forgets a `Task`, and not usable inside `.alert` / `.confirmationDialog`, which only accept plain `Button`s), `UpdateBannerView`, `WhatsNewView`, `ActionGrid`, `LoadingRow`, `HintRow`, `MusicPermissionBanner`, `StatTile`, `LabeledSlider`, `CooldownSliderPair`, `CommandAliasField`, `CommandSettingRow`, `ResponsiveRow`, `QRCodeImage`, `StreamerModeBadge`, `TwitchConnectionNotice`, `TwitchGlitchShape`, `ViewModifiers`, `DeepLinkAnchor` (`.deepLinkSection("slug")`: the scroll target + accent-ring flash for a settings deep link; slugs are kebab-case, unique per pane, and listed in the Deep links table of `settings.mdx`, so add the new row there when you tag a card), `SettingsNavRail` (shared two-column jump-nav rail + scroll-sync used by General, Debug, Song Requests, and History & Stats; sections conform `SettingsRailSection` and tag their top view with `.railSection(_:)`; panes that use it bypass `standardDetailScroll` in `SettingsView.detailPane` to own the full pane width). Sensitive fields wrap their value in a `StreamerMode.shared` check before rendering; when Streamer Mode is on, the value is replaced with a `••••••` mask and Copy/Open buttons are disabled. ### Key patterns - **Credentials**: All tokens/secrets stored via `KeychainService` (never UserDefaults). Twitch access, refresh, username, user ID, and configured channel are one revisioned atomic grant; never split an account transition back into per-field writes. A channel restored from backup may exist in UserDefaults only as a pending, nonauthoritative hint until OAuth commits it with the account. Keys defined in `AppConstants.Keychain`. - **Settings**: User preferences in `UserDefaults` via `@AppStorage`. Keys centralized in `AppConstants.UserDefaults`. Note: `currentSongCommandEnabled`, `lastSongCommandEnabled`, and `widgetHTTPEnabled` all default to `false`. `streamDeckControlEnabled` is the one security-relevant capability that defaults to **`true`**, and must keep doing so: the capability shipped already gated by the control token, so defaulting it off would disarm every Stream Deck in the field on the first launch after an update. Read it through `FeatureFlags.streamDeckControlEnabled` (which passes the explicit default), never `defaults.bool`, which reports `false` for "never set". +- **Settings deep links**: `wolfwave://` is registered in `Info.plist` (`CFBundleURLTypes`) and lands in `AppDelegate.application(_:open:)`. Pane ids are `SettingsSection.slug`, never `rawValue` (that is the visible title). Section ids are the string passed to `.deepLinkSection`. One `ScrollViewReader` in `SettingsView` scrolls every pane, including the ones that own their own `ScrollView`. - **Notifications**: Loose coupling via `NotificationCenter` (e.g., `TrackingSettingChanged`, `DockVisibilityChanged`). Names in `AppConstants.Notifications`. - **Thread safety**: `TwitchChatService` uses actor isolation; its synchronous observation bridges use small lock-backed snapshots. `DiscordRPCService` uses `ipcQueue` serial queue confinement plus `enabledLock` for thread safety. Logger uses a serial `DispatchQueue` for thread-safe file I/O. - **Bot commands**: Register new commands in `BotCommandDispatcher.registerDefaultCommands()`. Each command implements `BotCommand` protocol. Max response 500 chars, target <100ms execution. diff --git a/README.md b/README.md index 782747445..b07dd9cf8 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Your music plays. Everything else keeps up. - **Stream Widgets.** Drop-in browser-source overlay powered by a local WebSocket server with a per-install read-only overlay token, five themes (`Default`, `Dark`, `Light`, `Glass`, `Neon`), and five layouts (`Horizontal`, `Vertical`, `Compact`, `Vinyl`, `Classic`). Two-PC streamers can receive now-playing data from a second machine on the LAN. - **Queue Ticker Overlay.** Opt-in `?queueTicker=1` panel showing the next 3 song requests, title and requester, so viewers see their spot in line without asking chat. - **OBS-friendly by design.** Visual progress is batched at 10 Hz, rendering sleeps while hidden or unloaded, and reduced-motion mode removes continuous animation work. +- **Settings deep links.** `wolfwave://settings/twitch/custom-commands` opens Settings on the Twitch pane, scrolls to Custom Commands, and flashes it. Every pane and card has a stable name, listed in the [Settings docs](https://mrdemonwolf.github.io/wolfwave/docs/settings#deep-links). - **Stream Deck Control.** A separate control token authorizes play/pause, skip, request-queue, announce, block, and overlay-toggle commands (control protocol v3, twelve keys) only from this Mac; the read-only overlay token can never run them. The Elgato plugin lives at `apps/streamdeck/`, and its protocol is documented in [Stream Deck Control API](apps/native/docs/streamdeck-control-api.md). ### History & Stats diff --git a/apps/docs/content/docs/changelog.mdx b/apps/docs/content/docs/changelog.mdx index 4e6d4f4b9..89c02285a 100644 --- a/apps/docs/content/docs/changelog.mdx +++ b/apps/docs/content/docs/changelog.mdx @@ -21,6 +21,7 @@ The next release. These ship on the [Nightly channel](/docs/nightly) off `main` ### Added - **Custom commands can pick how they answer.** Each custom command now has a "How it's sent" setting: reply to the chatter (the default, and what existing commands keep doing), a plain chat message, or a Twitch announcement. Announcements need the signed-in account to be a mod and a one-time Twitch reconnect to grant the new permission; if Twitch refuses, the command falls back to a reply and the Custom Commands card tells you why. +- **Links that open Settings right where you need them.** WolfWave now answers `wolfwave://settings/` and `wolfwave://settings//
` links, so the docs can open the Twitch pane, scroll to Custom Commands, and flash it. Works whether Settings is closed or already open on another pane. A link to something that doesn't exist just opens General. Full list of names under [Deep links](/docs/settings#deep-links). - **Five new Stream Deck keys.** **Announce Song** posts what's playing to your chat, same wording as `!song`. **Reject Request** drops the request that's playing and tells chat it went, so the requester isn't left wondering (Skip just moves on silently). **Block Requester** bars the person who requested the current song, held for a second so you can't do it by accident. **Request Access** cycles who can request and shows the current setting on the key. **Now Playing** shows the track over its album art. See [Stream Deck](/docs/streamdeck). - **You can block people from requesting, not just songs.** Settings → Song Requests → Blocklist now takes a Twitch username as well as song titles and artists. A blocked person's requests are turned away before WolfWave even looks the song up. - **A Sponsor link you can turn off.** WolfWave now has a Sponsor item in the menu bar's Help submenu as well as the Help menu and the About pane. If you'd rather not be asked, turn off "Show sponsor links" in [Settings, About](/docs/settings#about). WolfWave is free and complete either way. diff --git a/apps/docs/content/docs/settings.mdx b/apps/docs/content/docs/settings.mdx index c111ad626..a86c68787 100644 --- a/apps/docs/content/docs/settings.mdx +++ b/apps/docs/content/docs/settings.mdx @@ -17,10 +17,14 @@ keywords: --- Open Settings from the menu bar wolf icon. A sidebar lists every pane; this page -tells you what lives where, with links to the deep-dives. +tells you what lives where, with links to the deep-dives. Each pane heading +below also carries a `wolfwave://` link that opens WolfWave straight to it +(see [Deep links](#deep-links)). ## General +Open in WolfWave: [`wolfwave://settings/general`](wolfwave://settings/general) + How WolfWave tracks your music and where it shows up. Four sections in one scrollable pane: @@ -44,6 +48,8 @@ Appearance and notification choices are preferences, so they travel with ## Song Requests +Open in WolfWave: [`wolfwave://settings/song-requests`](wolfwave://settings/song-requests) + The guided setup, the request policy preset (Open, Sub Only, Channel Point Only, Custom), queue rules, approval screening, hold mode, vote-skip, and the live queue view. The behavior is documented in @@ -52,6 +58,8 @@ queue view. The behavior is documented in ## Stream Widgets +Open in WolfWave: [`wolfwave://settings/stream-widgets`](wolfwave://settings/stream-widgets) + The OBS overlay: turn the connection on, drop in the ready-made browser source, and style it. Cards run in the order you need them, so the raw WebSocket feed (port, read-only overlay token, and the two addresses) sits last, under **Build @@ -60,6 +68,8 @@ and the message contract are in the [widget guide](/docs/widget). ## Stream Deck +Open in WolfWave: [`wolfwave://settings/stream-deck`](wolfwave://settings/stream-deck) + The control side, on its own page. Holds the **Allow Stream Deck commands** switch, the same-Mac control token, and the setup steps. It is separate from Stream Widgets on purpose: the overlay token is read-only and reachable across @@ -69,12 +79,16 @@ this Mac. Turning commands off leaves your overlay running. Full walkthrough: ## History & Stats +Open in WolfWave: [`wolfwave://settings/history-stats`](wolfwave://settings/history-stats) + Opt-in, on-device listening history with charts and the Monthly Wrap. What counts as a play and how retention works: [Listening History](/docs/listening-history). ## Twitch +Open in WolfWave: [`wolfwave://settings/twitch`](wolfwave://settings/twitch) + Connect your Twitch account (device code — no password typed into WolfWave), manage the chat bot, built-in command toggles and aliases, custom commands (each with its own delivery mode: reply, plain message, or announcement), and @@ -82,6 +96,8 @@ cooldowns. Command reference: [Bot Commands](/docs/bot-commands). ## Discord +Open in WolfWave: [`wolfwave://settings/discord`](wolfwave://settings/discord) + Rich Presence: show what you're listening to on your Discord profile, with optional buttons and an idle status when nothing is playing. @@ -97,11 +113,15 @@ instead of always guessing that Discord is closed: ## Software Update +Open in WolfWave: [`wolfwave://settings/software-update`](wolfwave://settings/software-update) + Check for updates, switch between the Stable and Nightly channels, and see what's new. Channel details: [Nightly Builds](/docs/nightly). ## Advanced +Open in WolfWave: [`wolfwave://settings/advanced`](wolfwave://settings/advanced) + Settings [backup and restore](/docs/backup), log export, the bug-report flow, and opt-in on-device diagnostics. **Export Logs** writes one diagnostics file: an environment summary (version, macOS, install method), the last crash if @@ -111,6 +131,8 @@ recovery callout appears here and says what crashed. ## About +Open in WolfWave: [`wolfwave://settings/about`](wolfwave://settings/about) + Version, credits, license, and links to the docs, community Discord, and acknowledgements. @@ -119,6 +141,45 @@ and you get a Sponsor item here, in the menu bar's Help submenu, and in the Help menu. Turn it off and all three disappear — the toggle itself stays, so you can turn it back on. WolfWave is free and fully functional either way. +## Deep links + +WolfWave registers the `wolfwave://` URL scheme. A link opens Settings on a +pane, scrolls to a card, and flashes it for a moment. It works whether Settings +is closed or already open somewhere else. Anything WolfWave doesn't recognise +opens General, so a stale link never errors. + +``` +wolfwave://settings General +wolfwave://settings/ one pane +wolfwave://settings//
one card inside that pane +``` + +Names are stable identifiers, not the titles you see on screen, so they survive +copy changes. + +| Pane | `` | `
` | +|---|---|---| +| General | `general` | `music`, `visibility`, `appearance`, `notifications` | +| Song Requests | `song-requests` | `vote-skip`, `queue`, `access`, `queue-settings`, `playback`, `commands`, `redemptions`, `blocklist` | +| Stream Widgets | `stream-widgets` | `server`, `browser-source`, `appearance`, `custom-overlay` | +| Stream Deck | `stream-deck` | `control`, `token`, `setup` | +| History & Stats | `history-stats` | `tracking`, `stats-command`, `retention`, `danger-zone` | +| Twitch | `twitch` | `account`, `live-only`, `commands`, `custom-commands` | +| Discord | `discord` | `presence`, `buttons`, `playlist`, `behavior` | +| Software Update | `software-update` | `updates` | +| Advanced | `advanced` | `diagnostics`, `artwork-cache`, `backup`, `danger-zone` | +| About | `about` | | + +Some cards only exist once a feature is on (Song Requests cards need setup +finished and the feature enabled; History cards need history on). A link to a +card that isn't showing opens the pane and stops there. + +Try one from Terminal: + +```bash +open "wolfwave://settings/twitch/custom-commands" +``` + ## Debug Only exists in development builds: inspectors, service controls, a live log diff --git a/apps/native/WolfWave/Core/AppConstants+Discord.swift b/apps/native/WolfWave/Core/AppConstants+Discord.swift index fafb88f19..79d628b04 100644 --- a/apps/native/WolfWave/Core/AppConstants+Discord.swift +++ b/apps/native/WolfWave/Core/AppConstants+Discord.swift @@ -11,9 +11,6 @@ import Foundation extension AppConstants { /// Discord Rich Presence constants. nonisolated enum Discord { - /// Settings section identifier for Discord configuration - static let settingsSection = "discordPresence" - /// IPC socket filename prefix (append 0-9 to find active socket) static let ipcSocketPrefix = "discord-ipc-" diff --git a/apps/native/WolfWave/Core/AppConstants+Twitch.swift b/apps/native/WolfWave/Core/AppConstants+Twitch.swift index 12639ca43..3a2dc0111 100644 --- a/apps/native/WolfWave/Core/AppConstants+Twitch.swift +++ b/apps/native/WolfWave/Core/AppConstants+Twitch.swift @@ -14,9 +14,6 @@ extension AppConstants { /// Base URL for Twitch Helix API endpoints static let apiBaseURL = "https://api.twitch.tv/helix" - /// Settings section identifier for Twitch configuration - static let settingsSection = "twitchIntegration" - /// Timeout in seconds for receiving the session_welcome WebSocket message static let sessionWelcomeTimeout: TimeInterval = 10.0 diff --git a/apps/native/WolfWave/Core/AppConstants+UserDefaults.swift b/apps/native/WolfWave/Core/AppConstants+UserDefaults.swift index e7115cc4f..91c121dde 100644 --- a/apps/native/WolfWave/Core/AppConstants+UserDefaults.swift +++ b/apps/native/WolfWave/Core/AppConstants+UserDefaults.swift @@ -45,9 +45,6 @@ extension AppConstants { /// authenticated account. Never used for connection decisions. static let twitchPendingImportedChannelName = "twitchPendingImportedChannelName" - /// Settings section to open next time (String, "twitchIntegration", etc.) - static let selectedSettingsSection = "selectedSettingsSection" - /// Whether WebSocket integration is enabled (Bool, default: false) static let websocketEnabled = "websocketEnabled" @@ -478,7 +475,6 @@ extension AppConstants { twitchReauthNeeded, twitchChannelName, twitchPendingImportedChannelName, - selectedSettingsSection, websocketEnabled, streamDeckControlEnabled, currentSongCommandEnabled, @@ -872,7 +868,6 @@ extension AppConstants { lastResolvedMusicPermission, lastLaunchCrashed, lastCrashSummary, - selectedSettingsSection, hasCompletedOnboarding, diagnosticsLaunchCount, updateSkippedVersion, diff --git a/apps/native/WolfWave/Core/AppConstants.swift b/apps/native/WolfWave/Core/AppConstants.swift index 396497db3..1cc285501 100644 --- a/apps/native/WolfWave/Core/AppConstants.swift +++ b/apps/native/WolfWave/Core/AppConstants.swift @@ -467,6 +467,10 @@ nonisolated enum AppConstants { /// Standard card padding static let cardPadding: CGFloat = DSDimension.Settings.cardPadding + /// Delay between a deep link selecting a pane and scrolling to its + /// section, so the pane has mounted its `DeepLinkAnchor` ids first. + static let deepLinkScrollDelayMs = 120 + /// Standard card corner radius (matches macOS 26 Liquid Glass card radius). static let cardCornerRadius: CGFloat = DSDimension.Settings.cardCornerRadius diff --git a/apps/native/WolfWave/Core/AppDelegate+MenuBar.swift b/apps/native/WolfWave/Core/AppDelegate+MenuBar.swift index 73a6dd195..837edd269 100644 --- a/apps/native/WolfWave/Core/AppDelegate+MenuBar.swift +++ b/apps/native/WolfWave/Core/AppDelegate+MenuBar.swift @@ -811,8 +811,7 @@ extension AppDelegate { } } else { // Connecting requires channel + credentials, open Twitch settings - Preferences.setSelectedSettingsSection(AppConstants.Twitch.settingsSection) - openSettings() + navigateSettings(to: SettingsDeepLink(pane: .twitchIntegration)) } } diff --git a/apps/native/WolfWave/Core/AppDelegate+Services.swift b/apps/native/WolfWave/Core/AppDelegate+Services.swift index dcdd5a46c..a3a8bb511 100644 --- a/apps/native/WolfWave/Core/AppDelegate+Services.swift +++ b/apps/native/WolfWave/Core/AppDelegate+Services.swift @@ -1006,8 +1006,7 @@ extension AppDelegate { } private func openSettingsToTwitch() { - Preferences.setSelectedSettingsSection(AppConstants.Twitch.settingsSection) - openSettings() + navigateSettings(to: SettingsDeepLink(pane: .twitchIntegration)) } /// Starts the app-lifetime Twitch validation owner. Kept async so the diff --git a/apps/native/WolfWave/Core/AppDelegate+Windows.swift b/apps/native/WolfWave/Core/AppDelegate+Windows.swift index a3273be97..902ab244d 100644 --- a/apps/native/WolfWave/Core/AppDelegate+Windows.swift +++ b/apps/native/WolfWave/Core/AppDelegate+Windows.swift @@ -47,6 +47,25 @@ extension AppDelegate { } } + /// Opens Settings at a specific pane (and optional section). Works whether + /// the window is closed or already open on another pane: `SettingsView` + /// observes `SettingsNavigation.shared.pending`. + func navigateSettings(to deepLink: SettingsDeepLink) { + SettingsNavigation.shared.pending = deepLink + openSettings() + } + + /// `wolfwave://settings/[/
]` entry point. Registered via + /// `CFBundleURLTypes` in `Info.plist`. Only the first URL is honoured; + /// anything malformed lands on General (see `SettingsDeepLink.parse`). + func application(_ application: NSApplication, open urls: [URL]) { + guard let url = urls.first else { return } + let link = SettingsDeepLink.parse(url) + // Only the resolved slugs are logged; the raw URL is caller-controlled. + Log.info("Settings deep link", fields: ["pane": link.pane.slug, "section": link.section ?? "-"]) + navigateSettings(to: link) + } + /// Shows the system standard About panel from the menu bar. /// /// About lives in two surfaces with intentionally different presentations: diff --git a/apps/native/WolfWave/Core/NotificationPayloads.swift b/apps/native/WolfWave/Core/NotificationPayloads.swift index 1c8e1cee9..c235fe024 100644 --- a/apps/native/WolfWave/Core/NotificationPayloads.swift +++ b/apps/native/WolfWave/Core/NotificationPayloads.swift @@ -140,8 +140,8 @@ extension NotificationCenter { /// Posts `.openSettingsRequested`: a bare signal asking `SettingsSceneBridge` /// (hosted in the hidden helper window) to invoke the live `openSettings` - /// environment action. Carries no payload; callers persist any requested - /// section through `Preferences.setSelectedSettingsSection` first. + /// environment action. Carries no payload; callers that want a specific + /// pane set `SettingsNavigation.shared.pending` first (`navigateSettings(to:)`). nonisolated func postOpenSettingsRequested() { post(name: .openSettingsRequested, object: nil) } diff --git a/apps/native/WolfWave/Core/Preferences.swift b/apps/native/WolfWave/Core/Preferences.swift index d6e9721a6..1353aed89 100644 --- a/apps/native/WolfWave/Core/Preferences.swift +++ b/apps/native/WolfWave/Core/Preferences.swift @@ -207,16 +207,6 @@ nonisolated enum Preferences { // MARK: - Settings / UI - /// Currently-selected sidebar section in the Settings window. `nil` when - /// the user has not navigated yet. - static var selectedSettingsSection: String? { - defaults.string(forKey: AppConstants.UserDefaults.selectedSettingsSection) - } - - static func setSelectedSettingsSection(_ value: String) { - defaults.set(value, forKey: AppConstants.UserDefaults.selectedSettingsSection) - } - /// Dock visibility mode persisted by the App Visibility setting (raw value). static var dockVisibility: String? { defaults.string(forKey: AppConstants.UserDefaults.dockVisibility) diff --git a/apps/native/WolfWave/Core/SettingsDeepLink.swift b/apps/native/WolfWave/Core/SettingsDeepLink.swift new file mode 100644 index 000000000..b35793e42 --- /dev/null +++ b/apps/native/WolfWave/Core/SettingsDeepLink.swift @@ -0,0 +1,71 @@ +// +// SettingsDeepLink.swift +// WolfWave +// +// Created by Nathanial Henniges on 2026-09-01. +// Copyright © 2026 MrDemonWolf, Inc. All rights reserved. +// + +import Foundation + +/// A parsed `wolfwave://settings/[/
]` URL. +/// +/// Parsing never fails: anything that is not a well-formed settings link +/// resolves to the General pane, which is the safe landing spot the ticket +/// asks for. An unknown section on a known pane keeps the pane and drops +/// the section, so a stale docs anchor still opens the right place. +nonisolated struct SettingsDeepLink: Equatable, Sendable { + /// Custom URL scheme registered in `Info.plist` (`CFBundleURLTypes`). + static let scheme = "wolfwave" + /// The only host this parser understands. + static let host = "settings" + + var pane: SettingsView.SettingsSection + /// Kebab-case anchor declared at a `.deepLinkSection(_:)` call site. + var section: String? + + init(pane: SettingsView.SettingsSection, section: String? = nil) { + self.pane = pane + self.section = section + } + + static let general = SettingsDeepLink(pane: .general) + + // MARK: - Parse + + /// Resolves a URL to a destination. Falls back to `.general` on any + /// scheme, host, or pane mismatch. + static func parse(_ url: URL) -> SettingsDeepLink { + guard url.scheme?.lowercased() == scheme, + url.host()?.lowercased() == host + else { return .general } + + let parts = url.pathComponents.filter { $0 != "/" && !$0.isEmpty } + guard !parts.isEmpty else { return .general } + guard parts.count <= 2 else { return .general } + guard let pane = SettingsView.SettingsSection(slug: parts[0]) else { return .general } + + var section: String? + if parts.count == 2 { + let candidate = parts[1].lowercased() + // ponytail: section existence is not validated here; an unknown + // anchor simply has nothing to scroll to and lands on the pane. + section = isValidSectionSlug(candidate) ? candidate : nil + } + return SettingsDeepLink(pane: pane, section: section) + } + + /// Inverse of `parse` for the cases `parse` accepts, as a string so a + /// caller never has to handle an impossible `nil`. + var urlString: String { + "\(Self.scheme)://\(Self.host)/" + pane.slug + (section.map { "/" + $0 } ?? "") + } + + /// Section slugs are `[a-z0-9-]+`; anything else is refused so a URL can + /// never smuggle odd characters into an anchor id. + static func isValidSectionSlug(_ slug: String) -> Bool { + !slug.isEmpty && slug.unicodeScalars.allSatisfy { + ($0 >= "a" && $0 <= "z") || ($0 >= "0" && $0 <= "9") || $0 == "-" + } + } +} diff --git a/apps/native/WolfWave/Core/SettingsNavigation.swift b/apps/native/WolfWave/Core/SettingsNavigation.swift new file mode 100644 index 000000000..b8c31119e --- /dev/null +++ b/apps/native/WolfWave/Core/SettingsNavigation.swift @@ -0,0 +1,29 @@ +// +// SettingsNavigation.swift +// WolfWave +// +// Created by Nathanial Henniges on 2026-09-01. +// Copyright © 2026 MrDemonWolf, Inc. All rights reserved. +// + +import Observation + +/// Hand-off between whoever wants Settings opened somewhere specific (a deep +/// link, the menu bar, a Twitch re-auth banner) and the live `SettingsView`. +/// +/// Replaces the old `selectedSettingsSection` UserDefaults hint, which was +/// read once on pane appear and so did nothing when the window was already +/// open. `SettingsView` observes `pending`, navigates, then clears it. +@MainActor +@Observable +final class SettingsNavigation { + static let shared = SettingsNavigation() + + /// Destination waiting to be applied by `SettingsView`. + var pending: SettingsDeepLink? + + /// Section slug currently flashing its highlight ring, if any. + var highlighted: String? + + private init() {} +} diff --git a/apps/native/WolfWave/Info.plist b/apps/native/WolfWave/Info.plist index 823513a9b..e067726c6 100644 --- a/apps/native/WolfWave/Info.plist +++ b/apps/native/WolfWave/Info.plist @@ -12,6 +12,19 @@ WolfWave NSHumanReadableCopyright Copyright © 2026 MrDemonWolf, Inc. All rights reserved. + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.mrdemonwolf.wolfwave + CFBundleURLSchemes + + wolfwave + + + SUFeedURL https://github.com/MrDemonWolf/wolfwave/releases/latest/download/appcast.xml SUPublicEDKey diff --git a/apps/native/WolfWave/Views/Advanced/AdvancedSettingsView.swift b/apps/native/WolfWave/Views/Advanced/AdvancedSettingsView.swift index ef1b5eee4..61880baf4 100644 --- a/apps/native/WolfWave/Views/Advanced/AdvancedSettingsView.swift +++ b/apps/native/WolfWave/Views/Advanced/AdvancedSettingsView.swift @@ -328,15 +328,18 @@ struct AdvancedSettingsView: View { // Diagnostics Card diagnosticsCard + .deepLinkSection("diagnostics") // Artwork Cache Card artworkCacheCard + .deepLinkSection("artwork-cache") // Diagnostics & Privacy (on-device MetricKit opt-in) DiagnosticsShareCardView() // Back Up / Restore Settings Card backupCard + .deepLinkSection("backup") Divider() .padding(.vertical, DSSpace.s1) @@ -389,6 +392,7 @@ struct AdvancedSettingsView: View { } } .cardStyle() + .deepLinkSection("danger-zone") } } diff --git a/apps/native/WolfWave/Views/Discord/DiscordSettingsView.swift b/apps/native/WolfWave/Views/Discord/DiscordSettingsView.swift index 4f62b44a5..86ac82982 100644 --- a/apps/native/WolfWave/Views/Discord/DiscordSettingsView.swift +++ b/apps/native/WolfWave/Views/Discord/DiscordSettingsView.swift @@ -71,14 +71,18 @@ struct DiscordSettingsView: View { var body: some View { VStack(alignment: .leading, spacing: DSSpace.s6) { connectionSection + .deepLinkSection("presence") if presenceEnabled && hasClientID { // Controls on the left, live preview on the right. ResponsiveRow // collapses to a single stacked column on narrow windows. ResponsiveRow { VStack(alignment: .leading, spacing: DSSpace.s6) { buttonsSection + .deepLinkSection("buttons") playlistSection + .deepLinkSection("playlist") behaviorSection + .deepLinkSection("behavior") } } right: { previewSection diff --git a/apps/native/WolfWave/Views/GeneralSettingsView.swift b/apps/native/WolfWave/Views/GeneralSettingsView.swift index 8fa7c2cff..fccd387ab 100644 --- a/apps/native/WolfWave/Views/GeneralSettingsView.swift +++ b/apps/native/WolfWave/Views/GeneralSettingsView.swift @@ -27,12 +27,16 @@ struct GeneralSettingsView: View { header MusicMonitorSettingsView(configure: configure) + .deepLinkSection("music") AppVisibilitySettingsView() + .deepLinkSection("visibility") AppearanceSettingsView() + .deepLinkSection("appearance") NotificationsSettingsView() + .deepLinkSection("notifications") } .frame(maxWidth: AppConstants.SettingsUI.maxContentWidth, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .center) diff --git a/apps/native/WolfWave/Views/HistoryStats/HistoryStatsSettingsView.swift b/apps/native/WolfWave/Views/HistoryStats/HistoryStatsSettingsView.swift index e8db2a67d..15f3bd986 100644 --- a/apps/native/WolfWave/Views/HistoryStats/HistoryStatsSettingsView.swift +++ b/apps/native/WolfWave/Views/HistoryStats/HistoryStatsSettingsView.swift @@ -173,6 +173,7 @@ struct HistoryStatsSettingsView: View { intro permissionBanner togglesCard + .deepLinkSection("tracking") if !historyEnabled { firstRunExplainer @@ -207,11 +208,14 @@ struct HistoryStatsSettingsView: View { if historyEnabled, statsEnabled { statsCommandCard + .deepLinkSection("stats-command") } if historyEnabled { manageBlock + .deepLinkSection("retention") dangerCard + .deepLinkSection("danger-zone") } } .frame(maxWidth: AppConstants.SettingsUI.maxContentWidth, alignment: .topLeading) diff --git a/apps/native/WolfWave/Views/SettingsView.swift b/apps/native/WolfWave/Views/SettingsView.swift index 8c1dcc802..cecddfe5c 100644 --- a/apps/native/WolfWave/Views/SettingsView.swift +++ b/apps/native/WolfWave/Views/SettingsView.swift @@ -42,7 +42,10 @@ struct SettingsView: View { // MARK: - Settings Section Enum /// Navigation sections in the settings sidebar. - enum SettingsSection: String, CaseIterable, Identifiable { + /// + /// `nonisolated` so the pure deep-link parser (`SettingsDeepLink`) and its + /// tests can use it off the main actor. + nonisolated enum SettingsSection: String, CaseIterable, Identifiable { case general = "General" case songRequests = "Song Requests" case websocket = "Stream Widgets" @@ -59,6 +62,34 @@ struct SettingsView: View { var id: Self { self } + /// Stable identifier used in `wolfwave://settings/` deep links. + /// Never derive this from `rawValue`: the raw value is the visible + /// title, and a copy edit must not break every link in the docs. + var slug: String { + switch self { + case .general: return "general" + case .songRequests: return "song-requests" + case .websocket: return "stream-widgets" + case .streamDeck: return "stream-deck" + case .historyStats: return "history-stats" + case .twitchIntegration: return "twitch" + case .discord: return "discord" + case .softwareUpdate: return "software-update" + case .advanced: return "advanced" + case .about: return "about" + #if DEBUG + case .debug: return "debug" + #endif + } + } + + /// Reverse of `slug`. Case-insensitive; `nil` for an unknown slug. + init?(slug: String) { + let wanted = slug.lowercased() + guard let match = Self.allCases.first(where: { $0.slug == wanted }) else { return nil } + self = match + } + /// Cases: `.debug` only present in DEBUG builds. static var allCases: [SettingsSection] { var cases: [SettingsSection] = [ @@ -130,6 +161,10 @@ struct SettingsView: View { /// Currently selected settings section @State private var selectedSection: SettingsSection = .general + /// In-flight deep-link scroll + flash. Replaced (and cancelled) by each + /// new link so a slow first link can never land on top of a second one. + @State private var deepLinkTask: Task? + /// Sidebar column visibility. Bound into `NavigationSplitView` so the /// automatic title-bar toggle (and any future programmatic show/hide) drives /// a single source of truth. @@ -154,7 +189,16 @@ struct SettingsView: View { // the only one we want. .toolbar(removing: .sidebarToggle) } detail: { - detailPane + // One reader for every pane: `ScrollViewProxy.scrollTo` reaches into + // whichever nested ScrollView contains the `DeepLinkAnchor` id, so + // panes that own their own scroll layout need no extra plumbing. + ScrollViewReader { proxy in + detailPane + .onAppear { applyPendingNavigation(proxy) } + .onChange(of: SettingsNavigation.shared.pending) { _, _ in + applyPendingNavigation(proxy) + } + } // Names the pane that is actually on screen, which is the only // signal a UI test has that a sidebar click finished. The row click // is asynchronous, so without this a test can only assert the row it @@ -175,14 +219,6 @@ struct SettingsView: View { .padding(.top, DSSpace.s2) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) - .onAppear { - if let requestedSection = Preferences.selectedSettingsSection { - if requestedSection == AppConstants.Twitch.settingsSection { - selectedSection = .twitchIntegration - } - DefaultsStore.store.removeObject(forKey: AppConstants.UserDefaults.selectedSettingsSection) - } - } // The sidebar toggle lives on the DETAIL toolbar, not the sidebar's. // SwiftUI's automatic toggle sits in the leading (sidebar) toolbar // segment; while the column animates to zero width that segment can't @@ -361,6 +397,35 @@ struct SettingsView: View { } } + // MARK: - Deep Links + + /// Consumes `SettingsNavigation.shared.pending`: selects the pane, then on + /// the next run-loop turn (after the pane has mounted its anchors) scrolls + /// to the section and triggers its highlight flash. + private func applyPendingNavigation(_ proxy: ScrollViewProxy) { + guard let link = SettingsNavigation.shared.pending else { return } + SettingsNavigation.shared.pending = nil + deepLinkTask?.cancel() + SettingsNavigation.shared.highlighted = nil + selectedSection = link.pane + guard let section = link.section else { return } + deepLinkTask = Task { @MainActor in + // ponytail: one tick is enough for a pane switch to lay out; bump + // to a layout-driven signal if a heavy pane ever misses the scroll. + guard (try? await Task.sleep(for: .milliseconds(AppConstants.SettingsUI.deepLinkScrollDelayMs))) != nil else { return } + withAnimation(.easeInOut(duration: DSMotion.Duration.slow)) { + proxy.scrollTo(DeepLinkAnchor(slug: section), anchor: .top) + } + SettingsNavigation.shared.highlighted = section + // Expiry lives here, not in the anchor: a slug with no mounted + // anchor (feature off, stale docs link) must still clear. + guard (try? await Task.sleep(for: .seconds(DSMotion.Duration.pulseSlow * 2))) != nil else { return } + if SettingsNavigation.shared.highlighted == section { + SettingsNavigation.shared.highlighted = nil + } + } + } + // MARK: - Sidebar Helpers /// Grouped sidebar layout. Headers keep related sections together so the @@ -395,11 +460,13 @@ struct SettingsView: View { .padding(.vertical, DSSpace.s1) TwitchCommandsCard(viewModel: twitchViewModel) + .deepLinkSection("commands") Divider() .padding(.vertical, DSSpace.s1) CustomCommandsCard(viewModel: twitchViewModel) + .deepLinkSection("custom-commands") } } diff --git a/apps/native/WolfWave/Views/Shared/DeepLinkAnchor.swift b/apps/native/WolfWave/Views/Shared/DeepLinkAnchor.swift new file mode 100644 index 000000000..738b9859a --- /dev/null +++ b/apps/native/WolfWave/Views/Shared/DeepLinkAnchor.swift @@ -0,0 +1,54 @@ +// +// DeepLinkAnchor.swift +// WolfWave +// +// Created by Nathanial Henniges on 2026-09-01. +// Copyright © 2026 MrDemonWolf, Inc. All rights reserved. +// + +import SwiftUI + +/// Scroll target id for a `wolfwave://settings//
` link. +/// Typed (not a bare `String`) so it can never collide with other `.id`s +/// in the same scroll view. +nonisolated struct DeepLinkAnchor: Hashable { + let slug: String +} + +extension View { + /// Marks this view as the `
` target of a settings deep link. + /// `slug` is kebab-case and must be unique within its pane. The view + /// flashes an accent ring when a link lands on it. + func deepLinkSection(_ slug: String) -> some View { + modifier(DeepLinkHighlight(slug: slug)) + } +} + +private struct DeepLinkHighlight: ViewModifier { + let slug: String + @State private var ringOpacity: Double = 0 + + func body(content: Content) -> some View { + content + .id(DeepLinkAnchor(slug: slug)) + .overlay { + RoundedRectangle(cornerRadius: AppConstants.SettingsUI.cardCornerRadius, style: .continuous) + .stroke(Color.accentColor, lineWidth: 2) + .opacity(ringOpacity) + .allowsHitTesting(false) + } + .onChange(of: SettingsNavigation.shared.highlighted == slug, initial: true) { _, isTarget in + guard isTarget else { return } + flash() + } + } + + /// Visual only. `SettingsView` owns clearing `highlighted`, so a slug + /// with no mounted anchor still expires. + private func flash() { + ringOpacity = 1 + withAnimation(.easeOut(duration: DSMotion.Duration.pulseSlow).delay(DSMotion.Duration.pulseSlow)) { + ringOpacity = 0 + } + } +} diff --git a/apps/native/WolfWave/Views/SoftwareUpdate/SoftwareUpdateSettingsView.swift b/apps/native/WolfWave/Views/SoftwareUpdate/SoftwareUpdateSettingsView.swift index 16a791f71..8ccdae339 100644 --- a/apps/native/WolfWave/Views/SoftwareUpdate/SoftwareUpdateSettingsView.swift +++ b/apps/native/WolfWave/Views/SoftwareUpdate/SoftwareUpdateSettingsView.swift @@ -104,6 +104,7 @@ struct SoftwareUpdateSettingsView: View { ) softwareUpdateCard + .deepLinkSection("updates") } .onAppear { isHomebrewInstall = Bundle.main.isHomebrewInstall diff --git a/apps/native/WolfWave/Views/SongRequest/SongRequestSettingsView.swift b/apps/native/WolfWave/Views/SongRequest/SongRequestSettingsView.swift index 0a1720d0f..b9d5c8fe2 100644 --- a/apps/native/WolfWave/Views/SongRequest/SongRequestSettingsView.swift +++ b/apps/native/WolfWave/Views/SongRequest/SongRequestSettingsView.swift @@ -69,6 +69,7 @@ struct SongRequestSettingsView: View { // Vote-skip skips the live Apple Music track even with no request // queue, so it stays reachable whether or not song requests are on. VoteSkipCard() + .deepLinkSection("vote-skip") // The configuration cards only appear once setup is finished and // the feature is on. Apple Music access is handled inside the @@ -78,13 +79,20 @@ struct SongRequestSettingsView: View { // on mid-stream (skip/hold/clear), so it leads. The set-once // configuration cards follow below. SongRequestQueueView() + .deepLinkSection("queue") SongRequestAccessCard() + .deepLinkSection("access") SongRequestQueueConfigCard() + .deepLinkSection("queue-settings") SongRequestPlaybackCard() + .deepLinkSection("playback") SongRequestCommandsCard(onManageLink: { openSetup(at: .shareLink) }) + .deepLinkSection("commands") SongRequestRedemptionsCard() + .deepLinkSection("redemptions") SongRequestBlocklistCard( blocklistProvider: { appDelegate?.songRequestService?.blocklist }) + .deepLinkSection("blocklist") } } .frame(maxWidth: AppConstants.SettingsUI.maxContentWidth, alignment: .topLeading) diff --git a/apps/native/WolfWave/Views/StreamDeck/StreamDeckSettingsView.swift b/apps/native/WolfWave/Views/StreamDeck/StreamDeckSettingsView.swift index 8594a6255..e3a9405ba 100644 --- a/apps/native/WolfWave/Views/StreamDeck/StreamDeckSettingsView.swift +++ b/apps/native/WolfWave/Views/StreamDeck/StreamDeckSettingsView.swift @@ -51,8 +51,11 @@ struct StreamDeckSettingsView: View { ) controlCard + .deepLinkSection("control") tokenCard + .deepLinkSection("token") setupCard + .deepLinkSection("setup") } .task { guard currentControlToken.isEmpty || currentOverlayToken.isEmpty else { return } diff --git a/apps/native/WolfWave/Views/Twitch/TwitchSettingsView.swift b/apps/native/WolfWave/Views/Twitch/TwitchSettingsView.swift index 09154a8ed..c90016f9f 100644 --- a/apps/native/WolfWave/Views/Twitch/TwitchSettingsView.swift +++ b/apps/native/WolfWave/Views/Twitch/TwitchSettingsView.swift @@ -41,6 +41,7 @@ struct TwitchSettingsView: View { value: viewModel.connectionError) authCard + .deepLinkSection("account") .transition(.opacity.combined(with: .move(edge: .bottom))) .animation( reduceMotion ? nil : DSMotion.Spring.snappy, @@ -48,6 +49,7 @@ struct TwitchSettingsView: View { || viewModel.authState.isInProgress)) chatCommandsCard + .deepLinkSection("live-only") } .onAppear { // Keychain reads + AppDelegate wiring run once per view model instance. diff --git a/apps/native/WolfWave/Views/WebSocket/WebSocketSettingsView.swift b/apps/native/WolfWave/Views/WebSocket/WebSocketSettingsView.swift index 9eeabd592..282355a1d 100644 --- a/apps/native/WolfWave/Views/WebSocket/WebSocketSettingsView.swift +++ b/apps/native/WolfWave/Views/WebSocket/WebSocketSettingsView.swift @@ -48,14 +48,18 @@ struct WebSocketSettingsView: View { // leading with it made a two-click setup look like a programming // task. WebSocketServerCard(serverState: serverState, localNetworkIP: localNetworkIP) + .deepLinkSection("server") WebSocketBrowserSourceCard(localNetworkIP: localNetworkIP) + .deepLinkSection("browser-source") .transition(.opacity) WebSocketWidgetAppearanceCard() + .deepLinkSection("appearance") .transition(.opacity) WebSocketCustomOverlayCard(localNetworkIP: localNetworkIP) + .deepLinkSection("custom-overlay") .transition(.opacity) } .task(id: websocketEnabled) { diff --git a/apps/native/WolfWaveTests/AppConstantsTests.swift b/apps/native/WolfWaveTests/AppConstantsTests.swift index 21364ace5..f9470706c 100644 --- a/apps/native/WolfWaveTests/AppConstantsTests.swift +++ b/apps/native/WolfWaveTests/AppConstantsTests.swift @@ -66,7 +66,6 @@ struct AppConstantsTests { #expect(!AppConstants.UserDefaults.trackingEnabled.isEmpty) #expect(!AppConstants.UserDefaults.dockVisibility.isEmpty) #expect(!AppConstants.UserDefaults.twitchReauthNeeded.isEmpty) - #expect(!AppConstants.UserDefaults.selectedSettingsSection.isEmpty) #expect(!AppConstants.UserDefaults.websocketEnabled.isEmpty) #expect(!AppConstants.UserDefaults.currentSongCommandEnabled.isEmpty) #expect(!AppConstants.UserDefaults.lastSongCommandEnabled.isEmpty) @@ -109,7 +108,6 @@ struct AppConstantsTests { @Test("Twitch constants are defined") func testTwitchConstants() async throws { #expect(AppConstants.Twitch.apiBaseURL == "https://api.twitch.tv/helix") - #expect(AppConstants.Twitch.settingsSection == "twitchIntegration") #expect(AppConstants.Twitch.sessionWelcomeTimeout == 10.0) #expect(AppConstants.Twitch.maxMessageLength == 500) #expect(AppConstants.Twitch.messageTruncationSuffix == "...") @@ -139,7 +137,6 @@ struct AppConstantsTests { @Test("Discord constants are defined") func testDiscordConstants() async throws { - #expect(AppConstants.Discord.settingsSection == "discordPresence") #expect(AppConstants.Discord.ipcSocketPrefix == "discord-ipc-") #expect(AppConstants.Discord.ipcSocketSlots == 10) #expect(AppConstants.Discord.rpcVersion == 1) diff --git a/apps/native/WolfWaveTests/SettingsDeepLinkTests.swift b/apps/native/WolfWaveTests/SettingsDeepLinkTests.swift new file mode 100644 index 000000000..c64aadf6c --- /dev/null +++ b/apps/native/WolfWaveTests/SettingsDeepLinkTests.swift @@ -0,0 +1,94 @@ +// +// SettingsDeepLinkTests.swift +// WolfWave +// +// Created by Nathanial Henniges on 2026-09-01. +// Copyright © 2026 MrDemonWolf, Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import WolfWave + +@Suite("Settings deep links") +struct SettingsDeepLinkTests { + private typealias Section = SettingsView.SettingsSection + + private func parse(_ string: String) -> SettingsDeepLink { + guard let url = URL(string: string) else { + Issue.record("not a URL: \(string)") + return .general + } + return SettingsDeepLink.parse(url) + } + + // MARK: - Slugs + + @Test("every pane slug is unique, kebab-case, and not its display title") + func slugsAreStable() { + let slugs = Section.allCases.map(\.slug) + #expect(Set(slugs).count == slugs.count) + for section in Section.allCases { + #expect(SettingsDeepLink.isValidSectionSlug(section.slug), "\(section.slug)") + #expect(section.slug != section.rawValue, "slug must not be the visible title") + #expect(Section(slug: section.slug) == section) + #expect(Section(slug: section.slug.uppercased()) == section) + } + } + + @Test("pinned pane slugs (docs link to these; changing one breaks the docs)") + func pinnedSlugs() { + #expect(Section.general.slug == "general") + #expect(Section.songRequests.slug == "song-requests") + #expect(Section.websocket.slug == "stream-widgets") + #expect(Section.streamDeck.slug == "stream-deck") + #expect(Section.historyStats.slug == "history-stats") + #expect(Section.twitchIntegration.slug == "twitch") + #expect(Section.discord.slug == "discord") + #expect(Section.softwareUpdate.slug == "software-update") + #expect(Section.advanced.slug == "advanced") + #expect(Section.about.slug == "about") + } + + // MARK: - Parse + + @Test("pane and section round-trip") + func roundTrip() { + for section in Section.allCases { + let bare = SettingsDeepLink(pane: section) + #expect(parse(bare.urlString) == bare) + let deep = SettingsDeepLink(pane: section, section: "custom-commands") + #expect(parse(deep.urlString) == deep) + } + #expect(SettingsDeepLink(pane: .twitchIntegration, section: "custom-commands").urlString + == "wolfwave://settings/twitch/custom-commands") + } + + @Test("tolerates case and trailing slash") + func lenientSyntax() { + #expect(parse("WOLFWAVE://Settings/Twitch/") == SettingsDeepLink(pane: .twitchIntegration)) + #expect(parse("wolfwave://settings/twitch/Custom-Commands") + == SettingsDeepLink(pane: .twitchIntegration, section: "custom-commands")) + } + + @Test("bare settings host opens General") + func bareHost() { + #expect(parse("wolfwave://settings") == .general) + #expect(parse("wolfwave://settings/") == .general) + } + + @Test("unknown pane, host, or scheme falls back to General") + func fallbacks() { + #expect(parse("wolfwave://settings/nope") == .general) + #expect(parse("wolfwave://settings/nope/custom-commands") == .general) + #expect(parse("wolfwave://now-playing") == .general) + #expect(parse("https://settings/twitch") == .general) + #expect(parse("wolfwave://settings/twitch/custom-commands/extra") == .general) + } + + @Test("unknown or malformed section keeps the pane and drops the section") + func badSection() { + #expect(parse("wolfwave://settings/twitch/custom%20commands") == SettingsDeepLink(pane: .twitchIntegration)) + #expect(parse("wolfwave://settings/twitch/../advanced") == .general) + } +} diff --git a/design-system/components/README.md b/design-system/components/README.md index e6a724de9..2fe48369e 100644 --- a/design-system/components/README.md +++ b/design-system/components/README.md @@ -33,6 +33,7 @@ blocks declare; the Tokens section next to it renders every token family. When y | MusicPermissionBanner | [music-permission-banner.md](music-permission-banner.md) | | AsyncActionButton | [async-action-button.md](async-action-button.md) | | CopyButton | [copy-button.md](copy-button.md) | +| DeepLinkAnchor | [deep-link-anchor.md](deep-link-anchor.md) | | CopyableURLRow | [copyable-url-row.md](copyable-url-row.md) | | OpenInBrowserButton | [open-in-browser-button.md](open-in-browser-button.md) | | SharePickerButton | [share-picker-button.md](share-picker-button.md) | diff --git a/design-system/components/deep-link-anchor.md b/design-system/components/deep-link-anchor.md new file mode 100644 index 000000000..0964e1083 --- /dev/null +++ b/design-system/components/deep-link-anchor.md @@ -0,0 +1,54 @@ +# DeepLinkAnchor + +**File:** [`apps/native/WolfWave/Views/Shared/DeepLinkAnchor.swift`](../../apps/native/WolfWave/Views/Shared/DeepLinkAnchor.swift) + +## Purpose +Marks a settings card as the `
` target of a `wolfwave://settings//
` link. Gives the card a typed scroll id and flashes an accent ring over it when a link lands, so the user's eye goes to the right card without anything else on the pane changing. + +## API +```swift +CustomCommandsCard(viewModel: twitchViewModel) + .deepLinkSection("custom-commands") +``` + +| Param | Type | Notes | +|---|---|---| +| `slug` | `String` | Kebab-case (`[a-z0-9-]+`), unique within its pane. This is the public id the docs link to; renaming it breaks those links. Add a row to the Deep links table in `apps/docs/content/docs/settings.mdx` when you tag a card. | + +`DeepLinkAnchor` (the `Hashable` id type) is `nonisolated` so the pure parser and tests can build one off the main actor. + +## Tokens used +| Token | Use | +|---|---| +| `AppConstants.SettingsUI.cardCornerRadius` | Ring radius, matches `.cardStyle()` | +| `Color.accentColor` | Ring stroke (follows the system accent on purpose) | +| `DSMotion.Duration.pulseSlow` | Hold before the fade starts, and the fade length | + +## Anatomy +```mermaid +flowchart LR + URL["wolfwave://settings/twitch/custom-commands"] --> Parse["SettingsDeepLink.parse"] + Parse --> Nav["SettingsNavigation.shared.pending"] + Nav --> View["SettingsView: select pane, scrollTo(DeepLinkAnchor)"] + View --> Flash["highlighted = slug, ring fades"] +``` + +## Accessibility +- The ring is `allowsHitTesting(false)` and carries no accessibility element; the card's own tree is untouched. +- Works under Reduce Motion: the fade is an opacity change, not movement. + +## Do / Don't +- **Do** tag the card root (the view that gets `.cardStyle()`), so the ring hugs the card. +- **Do** keep slugs stable once the docs link to them. +- **Don't** derive a slug from a title string. Titles change; links must not. +- **Don't** tag a view inside a `Lazy*Stack`; an unmounted anchor cannot be scrolled to. + +## Example +```swift +VStack(spacing: AppConstants.SettingsUI.sectionSpacing) { + WebSocketServerCard(serverState: serverState, localNetworkIP: localNetworkIP) + .deepLinkSection("server") + WebSocketBrowserSourceCard(localNetworkIP: localNetworkIP) + .deepLinkSection("browser-source") +} +```