diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index f268096874..cfee7cf742 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -93,6 +93,18 @@ struct ClaudeProviderImplementation: ProviderImplementation { set: { context.settings.claudeSwapShowSingleAccount = $0 }) return [ + ProviderSettingsToggleDescriptor( + id: "claude-model-scoped-weekly-usage-visible", + title: "Show model-specific weekly usage in widgets", + subtitle: "Shows model-specific Claude quotas, such as Fable, in desktop widgets.", + binding: context.boolBinding(\.claudeModelScopedWeeklyUsageVisible), + statusText: nil, + actions: [], + isVisible: nil, + isEnabled: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "claude-daily-routines-usage-visible", title: "Show Daily Routines usage", diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index cc7f35d76f..a723cf5a95 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -696,6 +696,14 @@ extension SettingsStore { } } + var claudeModelScopedWeeklyUsageVisible: Bool { + get { self.defaultsState.claudeModelScopedWeeklyUsageVisible } + set { + self.defaultsState.claudeModelScopedWeeklyUsageVisible = newValue + self.userDefaults.set(newValue, forKey: "claudeModelScopedWeeklyUsageVisible") + } + } + var codexSparkUsageVisible: Bool { get { self.defaultsState.codexSparkUsageVisible } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 0bd0973923..8174321212 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -58,6 +58,7 @@ extension SettingsStore { _ = self.copilotBudgetExtrasEnabled _ = self.showOptionalCreditsAndExtraUsage _ = self.claudeDailyRoutinesUsageVisible + _ = self.claudeModelScopedWeeklyUsageVisible _ = self.codexSparkUsageVisible _ = self.openAIWebAccessEnabled _ = self.openAIWebBatterySaverEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 09425a7b08..70587be067 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -507,6 +507,9 @@ extension SettingsStore { if Self.isRunningTests, claudeDailyRoutinesUsageVisibleDefault == nil { userDefaults.set(true, forKey: "claudeDailyRoutinesUsageVisible") } + // Model-scoped weekly rows are opt-in: a fresh install keeps widgets on the standard quota lanes. + let claudeModelScopedWeeklyUsageVisible = userDefaults.object( + forKey: "claudeModelScopedWeeklyUsageVisible") as? Bool ?? false let codexSparkUsageVisibleDefault = userDefaults.object(forKey: "codexSparkUsageVisible") as? Bool let codexSparkUsageVisible = codexSparkUsageVisibleDefault ?? true if Self.isRunningTests, codexSparkUsageVisibleDefault == nil { @@ -612,6 +615,7 @@ extension SettingsStore { claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, claudeDailyRoutinesUsageVisible: claudeDailyRoutinesUsageVisible, + claudeModelScopedWeeklyUsageVisible: claudeModelScopedWeeklyUsageVisible, codexSparkUsageVisible: codexSparkUsageVisible, openAIWebAccessEnabled: openAIWebAccessEnabled, openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index fc9d991821..557d7297da 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -58,6 +58,7 @@ struct SettingsDefaultsState { var claudeWebExtrasEnabledRaw: Bool var showOptionalCreditsAndExtraUsage: Bool var claudeDailyRoutinesUsageVisible: Bool + var claudeModelScopedWeeklyUsageVisible: Bool var codexSparkUsageVisible: Bool var openAIWebAccessEnabled: Bool var openAIWebBatterySaverEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+WidgetSnapshot.swift b/Sources/CodexBar/StatusItemController+WidgetSnapshot.swift index 7281cf6602..369feeeb82 100644 --- a/Sources/CodexBar/StatusItemController+WidgetSnapshot.swift +++ b/Sources/CodexBar/StatusItemController+WidgetSnapshot.swift @@ -4,6 +4,7 @@ extension StatusItemController { "enabled=\(self.store.enabledProvidersForDisplay().map(\.rawValue).joined(separator: ","))", "showUsed=\(self.settings.usageBarsShowUsed ? "1" : "0")", "optional=\(self.settings.showOptionalCreditsAndExtraUsage ? "1" : "0")", + "claudeScopedWeekly=\(self.settings.claudeModelScopedWeeklyUsageVisible ? "1" : "0")", ].joined(separator: "|") } diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index d20336d793..c6d991bf28 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -206,7 +206,8 @@ extension UsageStore { { Self.preservedClaudeWidgetUsage( from: previousEntry, - expectedQuotaOwnerKey: claudeQuotaOwnerKey) + expectedQuotaOwnerKey: claudeQuotaOwnerKey, + includesModelScopedWeeklyRows: self.settings.claudeModelScopedWeeklyUsageVisible) } else { nil } @@ -294,7 +295,8 @@ extension UsageStore { private nonisolated static func preservedClaudeWidgetUsage( from entry: WidgetSnapshot.ProviderEntry?, - expectedQuotaOwnerKey: String?) -> PreservedClaudeWidgetUsage? + expectedQuotaOwnerKey: String?, + includesModelScopedWeeklyRows: Bool) -> PreservedClaudeWidgetUsage? { guard let entry, entry.provider == .claude else { return nil } guard let expectedQuotaOwnerKey, @@ -309,6 +311,13 @@ extension UsageStore { let tertiary = entry.tertiary?.isSyntheticPlaceholder == true ? nil : entry.tertiary let usageRows = entry.usageRows?.filter { row in guard row.window?.isSyntheticPlaceholder != true else { return false } + // Rows persisted while the setting was on must not outlive it: without a live snapshot + // this preserved list is what widgets render, so re-apply the visibility filter here. + guard includesModelScopedWeeklyRows || + !row.id.hasPrefix(Self.claudeModelScopedWeeklyWindowIDPrefix) + else { + return false + } return switch row.id { case "primary": primary != nil case "secondary": secondary != nil @@ -467,6 +476,20 @@ extension UsageStore { title: metadata?.opusLabel ?? "Opus", percentLeft: snapshot.tertiary?.remainingPercent)) } + if provider == .claude, self.settings.claudeModelScopedWeeklyUsageVisible { + // Claude fetchers place model-scoped weekly quotas (for example, Fable) in extraRateWindows. + // Keep the widget projection generic so newly surfaced Claude model quotas appear without UI changes. + rows.append(contentsOf: (snapshot.extraRateWindows ?? []).compactMap { namedWindow in + guard namedWindow.id.hasPrefix(Self.claudeModelScopedWeeklyWindowIDPrefix), + namedWindow.usageKnown + else { return nil } + return WidgetSnapshot.WidgetUsageRowSnapshot( + id: namedWindow.id, + title: namedWindow.title, + percentLeft: namedWindow.window.remainingPercent, + window: namedWindow.window) + }) + } if provider == .kimi { // Keep persisted widget order stable and include only Kimi's intentional subscription lanes. let kimiWindowIDs = ["kimi-monthly", "kimi-code-7d"] @@ -482,6 +505,9 @@ extension UsageStore { return rows.filter { $0.percentLeft != nil } } + /// Identifier prefix Claude fetchers use for model-scoped weekly carve-outs (for example, Fable). + private nonisolated static let claudeModelScopedWeeklyWindowIDPrefix = "claude-weekly-scoped-" + private nonisolated static let antigravityQuotaSummaryWindowIDPrefix = "antigravity-quota-summary-" private nonisolated static let antigravityCompactFallbackWindowIDPrefix = "antigravity-compact-fallback-" diff --git a/Tests/CodexBarTests/ClaudeScopedWeeklySettingsTests.swift b/Tests/CodexBarTests/ClaudeScopedWeeklySettingsTests.swift new file mode 100644 index 0000000000..d66358388d --- /dev/null +++ b/Tests/CodexBarTests/ClaudeScopedWeeklySettingsTests.swift @@ -0,0 +1,59 @@ +import CodexBarCore +import Foundation +import Observation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct ClaudeScopedWeeklySettingsTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + + @Test + func `Claude model scoped widget usage defaults off persists and refreshes only menus`() async throws { + let suite = "ClaudeScopedWeeklySettingsTests-claude-model-scoped-widget-usage-visible" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.claudeModelScopedWeeklyUsageVisible == false) + let backgroundRevision = store.backgroundWorkSettingsRevision + let menuDidChange = ObservationFlag() + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + menuDidChange.set() + } + store.claudeModelScopedWeeklyUsageVisible = true + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(store.backgroundWorkSettingsRevision == backgroundRevision) + #expect(menuDidChange.get()) + + let reloaded = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(reloaded.claudeModelScopedWeeklyUsageVisible) + } +} diff --git a/Tests/CodexBarTests/ClaudeScopedWeeklyWidgetRowTests.swift b/Tests/CodexBarTests/ClaudeScopedWeeklyWidgetRowTests.swift new file mode 100644 index 0000000000..cef5335d33 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeScopedWeeklyWidgetRowTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing +@testable import CodexBarCore +@testable import CodexBarWidget + +struct ClaudeScopedWeeklyWidgetRowTests { + @Test + func `Claude widget renders projected model scoped weekly rows`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .claude, + updatedAt: Date(), + primary: nil, + secondary: nil, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot(id: "primary", title: "Session", percentLeft: 75), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "secondary", title: "Weekly", percentLeft: 50), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "claude-weekly-scoped-fable", + title: "Fable only", + percentLeft: 70), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + #expect(WidgetUsageRow.smallWidgetRowLimit(for: entry) == nil) + #expect(WidgetUsageRow.rows(for: entry).map(\.id) == [ + "primary", + "secondary", + "claude-weekly-scoped-fable", + ]) + } +} diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index be57f16545..e3d63741ed 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -344,6 +344,26 @@ struct ProviderSettingsDescriptorTests { #expect(routinesToggle.isEnabled?() == false) } + @Test + func `claude model scoped widget usage toggle is default off and independent`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-model-scoped-widget") + let context = fixture.settingsContext(provider: .claude) + let toggles = ClaudeProviderImplementation().settingsToggles(context: context) + let widgetToggle = try #require(toggles.first { + $0.id == "claude-model-scoped-weekly-usage-visible" + }) + + #expect(widgetToggle.binding.wrappedValue == false) + #expect(widgetToggle.isEnabled == nil) + #expect(widgetToggle.subtitle.contains("Fable")) + + widgetToggle.binding.wrappedValue = true + #expect(fixture.settings.claudeModelScopedWeeklyUsageVisible) + + fixture.settings.showOptionalCreditsAndExtraUsage = false + #expect(widgetToggle.isEnabled == nil) + } + @Test func `claude single swap account toggle persists and follows integration visibility`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-swap-single") diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index 417c3d25f6..24d7eb1df4 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -121,6 +121,162 @@ struct UsageStoreWidgetSnapshotTests { #expect(entry.usageRows?.compactMap(\.percentLeft) == [75, 50, 25, 90]) } + @Test + func `widget snapshot includes known Claude model scoped weekly quotas only when enabled`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-claude-model-scoped-weekly-rows" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 50, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "claude-weekly-scoped-unknown", + title: "Unknown model only", + window: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + usageKnown: false), + NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow(usedPercent: 10, windowMinutes: 1440, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .claude) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + // The setting is opt-in, so untouched defaults must project the standard quota lanes only. + #expect(settings.claudeModelScopedWeeklyUsageVisible == false) + store.persistWidgetSnapshot(reason: "claude-model-scoped-weekly-rows-default-test") + await store.widgetSnapshotPersistTask?.value + + let defaultEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(defaultEntry.usageRows?.map(\.id) == ["primary", "secondary"]) + + settings.claudeModelScopedWeeklyUsageVisible = true + store.persistWidgetSnapshot(reason: "claude-model-scoped-weekly-rows-test") + await store.widgetSnapshotPersistTask?.value + + let visibleEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(visibleEntry.usageRows?.map(\.id) == ["primary", "secondary", "claude-weekly-scoped-fable"]) + #expect(visibleEntry.usageRows?.map(\.title) == ["Session", "Weekly", "Fable only"]) + #expect(visibleEntry.usageRows?.compactMap(\.percentLeft) == [75, 50, 70]) + + settings.claudeModelScopedWeeklyUsageVisible = false + store.persistWidgetSnapshot(reason: "claude-model-scoped-weekly-rows-disabled-test") + await store.widgetSnapshotPersistTask?.value + + let hiddenEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(hiddenEntry.usageRows?.map(\.id) == ["primary", "secondary"]) + } + + @Test + func `widget snapshot drops persisted Claude model scoped weekly rows once the setting is off`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-claude-model-scoped-weekly-fallback" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.claudeModelScopedWeeklyUsageVisible = true + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 50, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: 30, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: updatedAt), + provider: .claude) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "claude-model-scoped-weekly-fallback-seed") + await store.widgetSnapshotPersistTask?.value + + let seededEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(seededEntry.usageRows?.map(\.id) == ["primary", "secondary", "claude-weekly-scoped-fable"]) + let quotaOwnerKey = try #require(seededEntry.quotaOwnerKey) + + // No live Claude snapshot: the projection now falls back to the persisted rows above. + store.snapshots.removeValue(forKey: .claude) + settings.claudeModelScopedWeeklyUsageVisible = false + store.persistWidgetSnapshot(reason: "claude-model-scoped-weekly-fallback-disabled") + await store.widgetSnapshotPersistTask?.value + + let fallbackEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(fallbackEntry.usageRows?.map(\.id) == ["primary", "secondary"]) + #expect(fallbackEntry.usageRows?.contains { $0.id.hasPrefix("claude-weekly-scoped-") } == false) + + // A persisted entry whose only rows are scoped carve-outs drops out of the projection entirely. + store.lastQueuedWidgetSnapshot = WidgetSnapshot( + entries: [ + WidgetSnapshot.ProviderEntry( + provider: .claude, + updatedAt: updatedAt, + primary: nil, + secondary: nil, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "claude-weekly-scoped-fable", + title: "Fable only", + percentLeft: 70), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + quotaOwnerKey: quotaOwnerKey), + ], + enabledProviders: [.claude], + generatedAt: updatedAt) + + store.persistWidgetSnapshot(reason: "claude-model-scoped-weekly-fallback-scoped-only") + await store.widgetSnapshotPersistTask?.value + + #expect(widgetSnapshots.last?.entries.contains { $0.provider == .claude } == false) + } + @Test func `widget snapshot includes antigravity grouped usage rows`() async throws { let suite = "UsageStoreWidgetSnapshotTests-antigravity-grouped" diff --git a/docs/claude.md b/docs/claude.md index 920d7822c5..d1ff202dea 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -84,7 +84,11 @@ Admin API key setup: - `extra_usage` → Extra usage cost (monthly spend/limit). - Preferences → Providers → Claude → Show Daily Routines usage hides only the Daily Routines row in menus and the provider preview. The global optional credits and extra usage setting is its master switch. The Claude-specific - setting does not change fetching, history, notifications, widgets, hooks, model-scoped weekly limits, or CLI output. + setting does not change fetching, history, notifications, widgets, model-scoped weekly limits, hooks, or CLI output. +- Preferences → Providers → Claude → Show model-specific weekly usage in widgets controls model-scoped weekly quota + rows in desktop widgets. It is off by default; turning it on displays every known Claude window with a + `claude-weekly-scoped-` identifier (for example, Fable). Turning it back off also drops scoped rows that a previous + snapshot persisted. It does not change fetching, the menu, history, notifications, hooks, or CLI output. - Successful OAuth login enables Claude and preserves the selected usage source. With the default Auto source, OAuth remains preferred when readable, while CLI/Web fallback stays available when OAuth credentials are not usable. - Plan inference: `subscriptionType` is preferred when present; `rate_limit_tier` falls back to diff --git a/docs/widgets.md b/docs/widgets.md index 28ef0c6ec8..eac656bb4b 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -18,6 +18,11 @@ read_when: requests without changing provider usage/status freshness or the user-selected provider refresh cadence. - Claude local cost/token history remains eligible for widget snapshots when its account does not expose numeric session or weekly quota data. +- Claude Usage widgets can show each known model-scoped weekly quota after the normal Session, Weekly, and Opus rows. + This includes Fable when Claude exposes it. The rows are opt-in via **Preferences → Providers → Claude → Show + model-specific weekly usage in widgets**; the setting is off by default and does not affect fetching or other + CodexBar surfaces. Turning it off also removes scoped rows kept from an earlier snapshot, including while no fresh + Claude quota data is available. - If no snapshot is available, widgets fall back to preview/empty data. ## Extension