diff --git a/Bubo/Presentation/Views/Components/Backlog/SmartActions.swift b/Bubo/Presentation/Views/Components/Backlog/SmartActions.swift index 958d65cb..1a5f9292 100644 --- a/Bubo/Presentation/Views/Components/Backlog/SmartActions.swift +++ b/Bubo/Presentation/Views/Components/Backlog/SmartActions.swift @@ -110,20 +110,31 @@ struct SmartActions: View { // expires, `chipRow` takes over again. if let applied = recentApplied, applied.isFresh { reasoningRow(applied) - } else { + } else if hasMeaningfulContent { chipRow } + // else: collapse the row entirely. With no primary chip + // (calm state), no ranked actions, and no trailing slot, + // the only thing left was an orphan «More» pill floating + // mid-popover — which is more chrome than signal. Hide it; + // the same actions are still reachable via ⌘K. } - // `DS.Animation.machineWork` — slow ease-out, no bounce. State - // transitions here read as «machine reasoning»: forecast flips - // from .fits to .over because the user added a long task, the - // calm `Plan day…` row is replaced by the hard «Schedule - // overflow» chip. Bounce would feel playful when the goal is a - // calm reasoning beat. The hash key combines the three signals - // that drive `resolvedState`. .animation(DS.Animation.machineWork, value: stateHash) } + /// True when `chipRow` will produce something other than a lone + /// «More» pill. Drives the empty-state collapse in `body` so the + /// chip row vanishes when the popover is calm and unranked, instead + /// of leaving a single floating button. + private var hasMeaningfulContent: Bool { + switch resolvedState { + case .hard, .soft: + return true + case .calm: + return !rankedCalmActions.isEmpty || trailing != nil + } + } + /// Single horizontal-scrolling chip row that absorbs the legacy /// hard/soft/calm card variants. Each state contributes at most one /// «primary» chip up front; ranked top-N calm actions follow when diff --git a/Bubo/Presentation/Views/Components/Banner/StatusBannerView.swift b/Bubo/Presentation/Views/Components/Banner/StatusBannerView.swift deleted file mode 100644 index f1032757..00000000 --- a/Bubo/Presentation/Views/Components/Banner/StatusBannerView.swift +++ /dev/null @@ -1,48 +0,0 @@ -import SwiftUI - -struct StatusBanner: View { - @Environment(\.activeSkin) private var skin - let icon: String - let text: String - let color: Color - - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - var body: some View { - HStack(spacing: DS.Spacing.sm) { - Image(systemName: icon) - .foregroundStyle(color) - .font(.footnote) - .symbolRenderingMode(.hierarchical) - .contentTransition(.symbolEffect(.replace)) - Text(text) - .font(.footnote) - .foregroundStyle(skin.resolvedTextPrimary) - } - // PRINCIPLES §2 — density: the banner carries one icon and one - // footnote-sized line of text, so 16/12 inner padding made the - // capsule sit visually heavier than the message it carries. - // 12/8 keeps it readable without dominating the popover when - // a network blip surfaces it. - .padding(.horizontal, DS.Spacing.md) - .padding(.vertical, DS.Spacing.sm) - .adaptiveBadgeFill(color) - .clipShape(Capsule()) - // Status banner sits inside the popover body, on the card plane (z1). - .elevation(.z1, skin: skin) - // Level 1: unified outer content margin so the banner hangs on - // the same vertical axis as the rest of the popover chrome. - .padding(.horizontal, DS.Spacing.contentMargin) - .padding(.vertical, DS.Spacing.xs) - .accessibilityElement(children: .combine) - .accessibilityLabel("Status: \(text)") - .transition( - reduceMotion - ? .opacity - : .asymmetric( - insertion: .move(edge: .top).combined(with: .opacity), - removal: .move(edge: .top).combined(with: .opacity).combined(with: .scale(scale: 0.95, anchor: .top)) - ) - ) - } -} diff --git a/Bubo/Presentation/Views/Components/Event/DaySectionView.swift b/Bubo/Presentation/Views/Components/Event/DaySectionView.swift index 5f5406db..171b30e9 100644 --- a/Bubo/Presentation/Views/Components/Event/DaySectionView.swift +++ b/Bubo/Presentation/Views/Components/Event/DaySectionView.swift @@ -88,14 +88,16 @@ struct DaySectionHeader: View { .frame(maxWidth: .infinity, alignment: .leading) .background( // `.day-header` translucent surface-window backdrop. - // Use a hairline darker overlay so the strip reads as a - // banner regardless of the skin's base colour. + // Tuned for the dark skins — at 0.04 the strip was visually + // identical to the popover bg. 0.10 on white over a dark + // base produces the same "slightly darker translucent + // banner" the prototype carries via `surface-window 92%`. Rectangle() - .fill(skin.resolvedTextPrimary.opacity(0.04)) + .fill(skin.resolvedTextPrimary.opacity(0.10)) ) .overlay(alignment: .bottom) { Rectangle() - .fill(skin.resolvedTextPrimary.opacity(0.06)) + .fill(skin.resolvedTextPrimary.opacity(0.14)) .frame(height: 0.5) } .accessibilityElement(children: .combine) diff --git a/Bubo/Presentation/Views/Components/Event/EventRowView.swift b/Bubo/Presentation/Views/Components/Event/EventRowView.swift index 0c0f17ae..8d29cb70 100644 --- a/Bubo/Presentation/Views/Components/Event/EventRowView.swift +++ b/Bubo/Presentation/Views/Components/Event/EventRowView.swift @@ -556,11 +556,11 @@ struct EventRowView: View { // `.bb-event .stripe`: a 3pt-wide vertical bar that // `align-self: stretch` spans the full row height, so a // 3-line event reads as one coloured block from top to bottom. - // Past rows fade, upcoming and «now» rows stay opaque. + // Opacity floor of 0.7 — at 0.45 the stripe disappeared into + // the dark popover bg on past / colorless rows (visible bug + // on the live screenshot). let baseColor: Color = event.colorTag?.color ?? skin.resolvedTextTertiary - let opacity: Double = event.colorTag == nil - ? 0.45 - : (event.isUpcoming || isHappeningNow ? 0.95 : 0.55) + let opacity: Double = (event.isUpcoming || isHappeningNow) ? 0.95 : 0.75 RoundedRectangle(cornerRadius: 1.5, style: .continuous) .fill(baseColor.opacity(opacity)) .frame(width: 3) @@ -569,24 +569,10 @@ struct EventRowView: View { .accessibilityLabel(isHappeningNow ? "Happening now" : "") } - // MARK: - Now pill (trailing badge) - - /// Filled orange capsule rendered in the row's trailing slot when - /// the current time falls inside this event. Mirrors the prototype's - /// `.bb-badge[data-style="filled"]` with `--system-orange`. Hidden - /// for past / upcoming rows. - @ViewBuilder - private var nowPill: some View { - if isHappeningNow { - Text("Now") - .font(.system(size: 10.5, weight: .bold, design: skin.resolvedFontDesign)) - .foregroundStyle(.white) - .padding(.horizontal, DS.Spacing.sm) - .padding(.vertical, DS.Spacing.xxs + 1) - .background(Capsule().fill(Color.orange)) - .accessibilityLabel("Happening now") - } - } + // The «happening now» signal is carried by `TimelineNowRule` — + // the red NOW · HH:MM hairline rendered in the day-group switch + // between past and current rows. An orange Now pill on the row + // itself was redundant and competed with the same cue. // MARK: - Time Column @@ -625,8 +611,6 @@ struct EventRowView: View { timeColumn(now) eventDetails Spacer(minLength: DS.Spacing.sm) - nowPill - .padding(.top, DS.Spacing.xxs) } .contentShape(Rectangle()) } diff --git a/Bubo/Presentation/Views/MenuBar/MenuBarView+MainContent.swift b/Bubo/Presentation/Views/MenuBar/MenuBarView+MainContent.swift index a0bf641f..693d6149 100644 --- a/Bubo/Presentation/Views/MenuBar/MenuBarView+MainContent.swift +++ b/Bubo/Presentation/Views/MenuBar/MenuBarView+MainContent.swift @@ -95,34 +95,98 @@ extension MenuBarView { // MARK: - Inline status row - /// Single thin status row — highest-priority issue only. - /// Replaces the stack of mutually-exclusive `StatusBanner`s and the - /// `PermissionBannersCarousel`. Hidden when everything is healthy. + /// Single thin status row — highest-priority issue only. Renders + /// as a quiet one-line caption with a small leading icon instead + /// of a full-width capsule banner: the «something's off» signal + /// shouldn't outshout the timeline it sits above. Hidden when + /// everything is healthy. + /// + /// Priority order (highest first): + /// 1. Calendar permission missing — actionable, blocks the + /// core data source. + /// 2. Reminders permission missing — same shape, lower + /// priority since reminders are an optional source. + /// 3. Offline — global system state, may resolve on its own. + /// 4. Cached data — showing data but it might be stale. + /// 5. Sync error — surfaced from the service. @ViewBuilder var inlineStatusRow: some View { - if !networkMonitor.isConnected { - StatusBanner( + if settings.isCalendarSyncEnabled, !calendarHasAccess { + inlineStatusLine( + icon: "calendar.badge.exclamationmark", + text: "Calendar access needed", + actionLabel: "Open Settings", + action: { openSettingsPane(.calendars) } + ) + } else if settings.isRemindersSyncEnabled, !remindersHasAccess { + inlineStatusLine( + icon: "checklist", + text: "Reminders access needed", + actionLabel: "Open Settings", + action: { openSettingsPane(.appleReminders) } + ) + } else if !networkMonitor.isConnected { + inlineStatusLine( icon: "wifi.slash", - text: "No internet — calendar data may be outdated", - color: skin.resolvedWarningColor + text: "Offline — calendar may be outdated" ) } else if reminderService.isUsingCache { - StatusBanner( + inlineStatusLine( icon: "arrow.triangle.2.circlepath", - text: "Showing cached data", - color: skin.resolvedWarningColor + text: "Showing cached data" ) - .frame(maxWidth: .infinity, alignment: .center) } else if let error = reminderService.syncError, settings.isCalendarSyncEnabled { - StatusBanner( + inlineStatusLine( icon: "exclamationmark.triangle.fill", - text: error, - color: skin.resolvedWarningColor + text: error ) - .frame(maxWidth: .infinity, alignment: .center) } } + /// Route the popover to a specific Settings pane and bring the + /// app to the front. Same path the empty state uses for + /// «Adjust calendars». + private func openSettingsPane(_ pane: SettingsView.SettingsPane) { + Haptics.tap() + SettingsViewModel.pendingPane = pane + openSettings() + NSApp.activate() + } + + @ViewBuilder + private func inlineStatusLine( + icon: String, + text: String, + actionLabel: String? = nil, + action: (() -> Void)? = nil + ) -> some View { + HStack(spacing: DS.Spacing.xs) { + Image(systemName: icon) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(skin.resolvedTextTertiary) + Text(text) + .font(.system(size: 11, weight: .medium, design: skin.resolvedFontDesign)) + .foregroundStyle(skin.resolvedTextTertiary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: DS.Spacing.xs) + if let actionLabel, let action { + Button(action: action) { + Text(actionLabel) + .font(.system(size: 11, weight: .semibold, design: skin.resolvedFontDesign)) + .foregroundStyle(skin.accentColor) + } + .buttonStyle(.plain) + .accessibilityLabel(actionLabel) + } + } + .padding(.horizontal, DS.Spacing.contentMargin) + .padding(.top, DS.Spacing.xxs) + .padding(.bottom, DS.Spacing.xs) + .accessibilityElement(children: .combine) + .accessibilityLabel(text) + } + // MARK: - Main Content var mainContent: some View {