From 6f638b42c71305a0c63cd6fc3348007a483f74cc Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:32:40 -0500 Subject: [PATCH 1/2] feat(glance,pip): the owner's mark and flame, ink for "All", one control vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four owner tweaks, 2026-10-10. 1) USAGE FLAME BESIDE EACH ROW'S LOGO in the PiP. 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 still live. 2) "ALL" IN INK, not the accent. It was the one control in the header wearing the accent while meaning nothing by it; the accent now appears only where it carries meaning. 3) THE OWNER'S MARK AT THE POPOVER'S TOP-LEFT, before "CodeCaps". The same black-on-transparent artwork the menu bar and PiP use, drawn as a template. This is the Glance popover, not the PiP — the PiP header is gone by the owner's own request in #193, and the provider logos identify the HUD there. 4) THE SCOPE SWITCH READS AS A TOGGLE. It had a full-height rule between the boxes and an outline around the pair, which reads as two adjacent rectangles rather than one control. It now has a filled track under an unselected segment, the same corner radius, accent fill and on-accent ink as the footer's Open CodeCaps — one control vocabulary in the window. Added a `controlTrack` token beside the existing control colours rather than inlining a colour. The header tests caught the first attempt and were right to: changing the header's HStack spacing from 0 to 4 did not add 4pt, it added 4pt to EVERY implicit gap in a row with eight or more children — about 32pt of a 570pt popover, enough to crowd the cluster outright. The gap now lives on the mark alone, and the mark is 12pt. The remaining 16pt is the mark's true cost, which the width-budget test now includes. 394 tests pass, 3 skipped, 0 failures. --- Sources/CodeCaps/GlanceViews.swift | 79 ++++++++++++++++++--- Sources/CodeCaps/PipWidget.swift | 14 ++++ Sources/CodeCaps/QuotaComponents.swift | 12 ++++ Tests/CodeCapsTests/GlanceToggleTests.swift | 6 +- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/Sources/CodeCaps/GlanceViews.swift b/Sources/CodeCaps/GlanceViews.swift index 407775d5..91e079e0 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 380cb178..d88f17fb 100644 --- a/Sources/CodeCaps/PipWidget.swift +++ b/Sources/CodeCaps/PipWidget.swift @@ -703,12 +703,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 fe592476..0defe2f5 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 196905e7..a56621af 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) } From 40954ce589722b17ad9d92a5bf42ad6698889dab Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:08:04 -0500 Subject: [PATCH 2/2] fix(pip): reserve the flame's width in the row arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kody caught this on #208. Adding the flame beside the logo widened the row, but `rowWidth(for:)` — the same number behind `fitSize`, `applyFit` and the horizontal strip's per-row frame — did not know about it. So the icon was clipped by the vertical list's clip shape, and overflowed its frame into the inter-row gap horizontally. The flame's width now lives in `rowWidth(for:hasFlame:)` rather than as an extra padding on the row view, because that one function is what the fit and refit path already consults; putting it anywhere else would leave the same desync behind. One spiking row widens the whole strip: rows are a uniform width by design, and mixed widths would defeat the grid the owner asked for. A test asserts that a spiking row is wider by exactly `flameWidth` at every detail level that draws a logo, and by nothing at the levels that do not. --- Sources/CodeCaps/PipWidget.swift | 39 +++++++++++++++++------ Tests/CodeCapsTests/PipMetricsTests.swift | 13 ++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/Sources/CodeCaps/PipWidget.swift b/Sources/CodeCaps/PipWidget.swift index d88f17fb..2114b1e1 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) } diff --git a/Tests/CodeCapsTests/PipMetricsTests.swift b/Tests/CodeCapsTests/PipMetricsTests.swift index 6889733f..95410661 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)