From 53d52bb705d286c4e3d3fe5469caf08b7d55b8f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:35:05 +0000 Subject: [PATCH 1/2] ui: fix popover regressions from simplify pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeted fixes for issues spotted in the live macOS screenshot of PR #535: 1. Cached-data banner stopped dominating the popover. The previous "consolidated banner" path reused `StatusBanner` (full-width capsule with border) for all three system states, so an active cache pinned a loud yellow pill above the timeline. Replace with a quiet one-line caption (10pt SF Symbol + 11pt tertiary text) and delete the now-orphan `StatusBannerView`. 2. SmartActions chip row hides itself when calm-state with no ranked chips and no trailing slot — previously the lone «More» pill sat centred mid-popover like an orphan in the Backlog view. 3. Day-section banner overlay bumped from 0.04 → 0.10 (background) and 0.06 → 0.14 (bottom border). At 0.04 on the dark skins the strip was visually identical to the popover background. 4. Event-row stripe opacity floored at 0.75 for past / colourless rows; the old 0.45 floor produced an almost invisible grey stripe for events without a calendar colour tag. 5. Removed the orange «Now» pill from the row trailing slot. The red `TimelineNowRule` hairline that renders between past and current events already carries the «happening now» signal — carrying both was double-flagging the same moment. --- .../Components/Backlog/SmartActions.swift | 27 +++++++---- .../Components/Banner/StatusBannerView.swift | 48 ------------------- .../Components/Event/DaySectionView.swift | 10 ++-- .../Views/Components/Event/EventRowView.swift | 32 ++++--------- .../MenuBar/MenuBarView+MainContent.swift | 45 +++++++++++------ 5 files changed, 64 insertions(+), 98 deletions(-) delete mode 100644 Bubo/Presentation/Views/Components/Banner/StatusBannerView.swift 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..6fc8171a 100644 --- a/Bubo/Presentation/Views/MenuBar/MenuBarView+MainContent.swift +++ b/Bubo/Presentation/Views/MenuBar/MenuBarView+MainContent.swift @@ -95,34 +95,51 @@ 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. @ViewBuilder var inlineStatusRow: some View { if !networkMonitor.isConnected { - StatusBanner( + 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) } } + @ViewBuilder + private func inlineStatusLine(icon: String, text: String) -> 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: 0) + } + .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 { From d01cc27d3bbd74f0a7cbbd19336dcc0a4a0a5bd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:46:46 +0000 Subject: [PATCH 2/2] ui: restore Calendar / Reminders permission prompts as inline status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first simplification pass collapsed the `PermissionBannersCarousel` into oblivion; missing-permission was left undetectable in the popover unless the user opened Settings on their own. Restore the prompt as part of the existing quiet inline status row, not as a heavy capsule banner. The row already mirrored permission state into `calendarHasAccess` / `remindersHasAccess`, so no service plumbing changed — only the renderer learned a new `actionLabel + action` slot that routes to the right Settings pane. Priority above offline / cache / sync error: a missing permission blocks the data source and is actionable in one tap. --- .../MenuBar/MenuBarView+MainContent.swift | 53 +++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/Bubo/Presentation/Views/MenuBar/MenuBarView+MainContent.swift b/Bubo/Presentation/Views/MenuBar/MenuBarView+MainContent.swift index 6fc8171a..693d6149 100644 --- a/Bubo/Presentation/Views/MenuBar/MenuBarView+MainContent.swift +++ b/Bubo/Presentation/Views/MenuBar/MenuBarView+MainContent.swift @@ -100,9 +100,32 @@ extension MenuBarView { /// 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 { + 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: "Offline — calendar may be outdated" @@ -120,8 +143,23 @@ extension MenuBarView { } } + /// 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) -> some View { + 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)) @@ -131,7 +169,16 @@ extension MenuBarView { .foregroundStyle(skin.resolvedTextTertiary) .lineLimit(1) .truncationMode(.tail) - Spacer(minLength: 0) + 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)