diff --git a/Sources/CodeCaps/GlanceViews.swift b/Sources/CodeCaps/GlanceViews.swift index 407775d..91e079e 100644 --- a/Sources/CodeCaps/GlanceViews.swift +++ b/Sources/CodeCaps/GlanceViews.swift @@ -892,7 +892,20 @@ struct GlanceHeaderBar: View { } var body: some View { + // Spacing stays ZERO: this row separates its groups with explicit + // Spacers, and a non-zero spacing silently added itself to EVERY + // implicit gap in a header with eight or more children — about 32pt of + // a 570pt popover, which is what pushed the cluster into crowding. + // The mark's own gap is on the mark instead. HStack(spacing: 0) { + // Owner 2026-10-10: "in top left should have the black on + // transparent codecaps logo". The owner's own artwork, drawn as a + // template so one file is correct on both appearances. This is the + // Glance popover, not the PiP: the PiP header is gone by the + // owner's own request (#193), and the provider logos identify the + // HUD there. + GlanceBrandMark() + .padding(.trailing, Metrics.glanceBrandMarkGap) Text("CodeCaps") .font(.system(size: 13, weight: .semibold)) .lineLimit(1) @@ -969,7 +982,45 @@ struct GlanceHeaderBar: View { } } -/// The two-box switch between From Mac and From Fleet, in Title Case. The two +/// The owner's cropped black-on-transparent mark, resolved through the same +/// bundle the menu bar and PiP use and drawn as a template. Never re-cut, +/// re-exported or substituted. +struct GlanceBrandMark: View { + @Environment(\.colorScheme) private var colorScheme + static let asset: NSImage? = { + guard let url = ResourceBundle.resolved?.url(forResource: "CodeCapsMenuBarIcon", withExtension: "png"), + let image = NSImage(contentsOf: url) else { return nil } + image.isTemplate = true + return image + }() + + /// The frame is applied to EACH branch, not to the Group around them. + /// A `.resizable()` image proposes its natural size, and the menu-bar + /// artwork is 850pt square: under a `.fixedSize` header that leaked ~40pt + /// of the 570pt popover's width into a 12pt mark. Clamping the Group from + /// the outside is too late — the ideal width has already been spent. + var body: some View { + Group { + if let image = Self.asset { + Image(nsImage: image) + .resizable() + .renderingMode(.template) + .interpolation(.high) + .foregroundStyle(colorScheme == .dark ? Color.white : Theme.ink) + .frame(width: Metrics.glanceBrandMarkSize, height: Metrics.glanceBrandMarkSize) + } else { + // Only if the owner's artwork is ever missing from the bundle. + Image(systemName: "gauge.with.dots.needle.50percent") + .font(.system(size: Metrics.glanceBrandMarkSize)) + .foregroundStyle(Theme.accent) + .frame(width: Metrics.glanceBrandMarkSize, height: Metrics.glanceBrandMarkSize) + } + } + .accessibilityHidden(true) + } +} + +/// The two-box switch between From Mac and All Sources, in Title Case. The two /// boxes are the same width, so the switch does not change shape as it flips. struct GlanceViewToggle: View { @Binding var selection: GlanceViewMode @@ -980,17 +1031,22 @@ struct GlanceViewToggle: View { private static let widest = GlanceViewMode.allCases.map(\.title).max { $0.count < $1.count } ?? "" var body: some View { + // Owner 2026-10-10: "the from mac and from fleet should look more like + // a toggle than just outline basic shapes and should match in style + // more with the open codecaps in bottom right". + // + // It had a full-height 1pt rule between the boxes and an outline around + // the whole pair, which reads as two adjacent rectangles rather than one + // control. It now reads as the footer's prominent button did: a solid + // filled selected segment sitting on a track, with the hairline around + // the whole thing instead of down the middle. Same corner radius, same + // accent fill, same on-accent ink — one control vocabulary in the window. HStack(spacing: 0) { - ForEach(Array(GlanceViewMode.allCases.enumerated()), id: \.element) { index, mode in - if index > 0 { - Rectangle() - .fill(Theme.controlBorder) - .frame(width: 1, height: Metrics.glanceHeaderControlHeight) - } + ForEach(Array(GlanceViewMode.allCases.enumerated()), id: \.element) { _, mode in segment(mode) } } - .background(Theme.surface) + .background(Theme.controlTrack) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Theme.controlBorder)) .fixedSize() @@ -1008,7 +1064,7 @@ struct GlanceViewToggle: View { .font(.system(size: 11, weight: .semibold)) .lineLimit(1) .fixedSize() - .foregroundStyle(selected ? Theme.onAccent : .secondary) + .foregroundStyle(selected ? Theme.onAccent : Theme.ink.opacity(0.7)) .padding(.horizontal, Metrics.glanceHeaderSegmentPadding) .frame(height: Metrics.glanceHeaderControlHeight) .background(selected ? model.accentColor : Color.clear) @@ -1051,7 +1107,10 @@ struct GlanceAlarmAllToggle: View { Text("All") .font(.system(size: 13, weight: .semibold)) } - .foregroundStyle(tint) + // Owner 2026-10-10: "the All next to alarm should be black or normal + // dark text not accent color". Ink follows the appearance and the + // high-contrast setting; the accent stays where it carries meaning. + .foregroundStyle(Theme.ink) .frame(height: Metrics.glanceHeaderControlHeight) .contentShape(Rectangle()) .fixedSize() diff --git a/Sources/CodeCaps/PipWidget.swift b/Sources/CodeCaps/PipWidget.swift index 380cb17..2114b1e 100644 --- a/Sources/CodeCaps/PipWidget.swift +++ b/Sources/CodeCaps/PipWidget.swift @@ -168,18 +168,30 @@ enum PipMetrics { static let horizontalPeek: CGFloat = 64 /// The width one row needs at a given detail level. - static func rowWidth(for detail: Detail) -> CGFloat { + /// + /// `hasFlame` widens the row when a usage spike puts the flame beside the + /// logo. It has to be part of this and not just an extra padding on the + /// row: the same number drives `fitSize` and `applyFit`, so leaving it out + /// both clipped the icon in the vertical list and let it overflow the + /// fixed frame in the horizontal one. + static func rowWidth(for detail: Detail, hasFlame: Bool = false) -> CGFloat { + let base: CGFloat switch detail { - case .full: return fullMinWidth - case .noCountdown: return noCountdownMinWidth - case .singleMeter: return singleMeterMinWidth - case .logoOnly: return logoOnlyMinWidth - case .minimal: return minimalMinWidth - case .barsOnly: return barsOnlyMinWidth - case .singleBar: return singleMeterMinWidth + case .full: base = fullMinWidth + case .noCountdown: base = noCountdownMinWidth + case .singleMeter: base = singleMeterMinWidth + case .logoOnly: base = logoOnlyMinWidth + case .minimal: base = minimalMinWidth + case .barsOnly: base = barsOnlyMinWidth + case .singleBar: base = singleMeterMinWidth } + return hasFlame && detail.showsProviderLogo ? base + flameWidth : base } + /// The flame's own width plus its gap from the logo. Fixed so the row + /// arithmetic above can be exact rather than approximate. + static let flameWidth: CGFloat = rowSpacing + 10 + /// Whether the panel is wide enough to scroll its rows sideways. /// /// Owner 2026-10-10: "once it gets to where it is in the screenshot or @@ -463,7 +475,9 @@ struct PipWidgetView: View { /// edge, so scrolling never hides which platform you are looking at — /// which was the whole point of the owner's request. private func horizontalRows(detail: PipMetrics.Detail) -> some View { - let rowWidth = PipMetrics.rowWidth(for: detail) + // Wide enough for the widest pinned row, including a flame if any row + // has one, so no row is clipped by the frame below. + let rowWidth = PipMetrics.rowWidth(for: detail, hasFlame: anyRowSpiking) return ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: PipMetrics.rowSpacing * 2) { ForEach(targetRows) { row in @@ -485,6 +499,13 @@ struct PipWidgetView: View { /// button to appear when the mouse is anywhere over the window. @State private var isHovering = false + /// Whether any pinned row currently shows a usage spike. One answer for + /// the whole strip: rows are a uniform width by design, so one spiking row + /// widens them all rather than each row being a different size. + private var anyRowSpiking: Bool { + targetRows.contains { model.hasActiveRunawayAnomaly(for: $0) } + } + private var targetRows: [DisplaySection] { PipWidgetController.targetRows(for: model) } @@ -703,12 +724,26 @@ struct PipWidgetView: View { .prefix(detail.maxMeters) ) + let spiking = model.hasActiveRunawayAnomaly(for: row) + return HStack(spacing: PipMetrics.rowSpacing) { if detail.showsProviderLogo { PlatformLogo(providerKey: row.providerKey, size: PipMetrics.providerLogoSize, style: markStyle(for: row), iconHint: row.poolKey == nil ? row.section.iconHint : nil) + // Owner 2026-10-10: "after the platform name (or logo if no + // name shown) there should be a little flame for usage spike". + // Beside the LOGO rather than after the name, because the name + // is the first thing the width ladder drops — at narrow widths + // this is the only place the signal can live. + if spiking { + Image(systemName: "flame.fill") + .font(.system(size: PipMetrics.providerLogoSize * 0.7)) + .foregroundStyle(Theme.warning) + .help("Usage spike detected on this platform") + .accessibilityLabel("Usage spike detected on this platform") + } } if detail.showsTitle { diff --git a/Sources/CodeCaps/QuotaComponents.swift b/Sources/CodeCaps/QuotaComponents.swift index fe59247..0defe2f 100644 --- a/Sources/CodeCaps/QuotaComponents.swift +++ b/Sources/CodeCaps/QuotaComponents.swift @@ -248,6 +248,12 @@ enum Theme { ? dyn(NSColor.black.withAlphaComponent(0.92), NSColor.white.withAlphaComponent(0.95)) : dyn(NSColor.black.withAlphaComponent(0.16), NSColor.white.withAlphaComponent(0.22)) + /// The filled track a segmented control sits on, so an unselected segment + /// reads as "not chosen" rather than as an outlined box. Owner 2026-10-10. + static let controlTrack: Color = highContrast + ? dyn(NSColor.black.withAlphaComponent(0.10), NSColor.white.withAlphaComponent(0.16)) + : dyn(NSColor.black.withAlphaComponent(0.06), NSColor.white.withAlphaComponent(0.10)) + /// A control that is present but off, such as an unchecked row bell: /// visible enough to find, quiet enough not to read as a setting. static let faint: Color = highContrast @@ -391,6 +397,12 @@ enum Metrics { /// was the 10pt every header gap used, which read as cramped (owner delta, /// 2026-09-30); twice that sets the title apart from the controls. static let glanceHeaderTitleGap: CGFloat = 20 + + /// The owner's mark at the top-left of the Glance header, 2026-10-10. + /// Deliberately small: the popover is 570pt wide and the header already + /// carries the title, the scope switch, the bell and the two buttons. + static let glanceBrandMarkSize: CGFloat = 12 + static let glanceBrandMarkGap: CGFloat = 4 /// The least space between the switch and the right-hand cluster. The /// popover is wide, so the two only get this close if the cluster grows. static let glanceHeaderClusterGap: CGFloat = 16 diff --git a/Tests/CodeCapsTests/GlanceToggleTests.swift b/Tests/CodeCapsTests/GlanceToggleTests.swift index 196905e..a56621a 100644 --- a/Tests/CodeCapsTests/GlanceToggleTests.swift +++ b/Tests/CodeCapsTests/GlanceToggleTests.swift @@ -405,7 +405,11 @@ final class GlanceToggleTests: XCTestCase { // the gutters, title, switch, bell, enlarged refresh button, and settings button. let toggle = NSHostingView(rootView: GlanceViewToggle(selection: .constant(.fromMac), model: model)).fittingSize.width let bell = NSHostingView(rootView: GlanceAlarmAllToggle(isOn: .constant(true), model: model)).fittingSize.width - let expected = Metrics.glanceGutter * 2 + width("CodeCaps", size: 13, weight: .semibold) + // The header also carries the owner's mark at the top-left (owner + // 2026-10-10), so its size and gap are part of the budget here too. + let expected = Metrics.glanceGutter * 2 + + Metrics.glanceBrandMarkSize + Metrics.glanceBrandMarkGap + + width("CodeCaps", size: 13, weight: .semibold) + 8 + toggle + 12 + bell + 12 + 22 + 6 + 22 XCTAssertEqual(headerIdealWidth(parts: [], model: model), expected, accuracy: 3) } diff --git a/Tests/CodeCapsTests/PipMetricsTests.swift b/Tests/CodeCapsTests/PipMetricsTests.swift index 6889733..9541066 100644 --- a/Tests/CodeCapsTests/PipMetricsTests.swift +++ b/Tests/CodeCapsTests/PipMetricsTests.swift @@ -109,6 +109,19 @@ final class PipMetricsTests: XCTestCase { } } + /// Kody caught this on #208: the flame beside the logo added width that + /// `rowWidth(for:)` did not know about, so it was clipped in the vertical + /// list and overflowed the fixed frame in the horizontal strip. + func testARowCarryingAFlameIsWiderThanOneWithout() { + for level in PipMetrics.Detail.allCases { + XCTAssertEqual( + PipMetrics.rowWidth(for: level, hasFlame: true) - PipMetrics.rowWidth(for: level), + level.showsProviderLogo ? PipMetrics.flameWidth : 0, + "\(level) must reserve exactly the flame's width when it draws one") + } + XCTAssertGreaterThan(PipMetrics.flameWidth, 0, "the flame is not free") + } + func testNarrowWindowStillShowsBarAndPercent() { // The owner's floor: only the bars and the percentages remain. let detail = PipMetrics.detail(forWidth: PipMetrics.minWidth)