diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index 4e164a7..d0835c2 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -386,6 +386,15 @@ struct BoardInfoView: View { } ) .disabled(!store.acceptsBoardMutations) + // **Pinned to the control's own intrinsic size** (owner's 2026-08-10 follow-up on + // Pipeline card 5004c540: the two pickers' ratios have to actually render, here + // too). `SymbolGlyphView.sizeThatFits` honours any explicit finite width proposal + // — right for a caller that wants to constrain it, but wrong beside + // `BoardRenameField`'s flexible `TextField`: an `HStack` proposes each less- + // flexible child a share of the row's *remaining* width before the field's own + // turn, and without `.fixedSize()` the rectangle would happily accept whatever + // share that is rather than holding its 5:4 ratio. + .fixedSize() BoardRenameField(store: store) } } diff --git a/Kanban/UI/Card/CardSidebarSections.swift b/Kanban/UI/Card/CardSidebarSections.swift index 94f745c..9c76a16 100644 --- a/Kanban/UI/Card/CardSidebarSections.swift +++ b/Kanban/UI/Card/CardSidebarSections.swift @@ -143,6 +143,14 @@ struct CardStyleSection: View { // and the board's inline-editing rule alike, unchanged across the layout reshape and the // control's own two-zone-to-one-zone change. .disabled(!store.acceptsBoardMutations) + // **Pinned to the control's own intrinsic size** (owner's 2026-08-10 follow-up: the two + // pickers' ratios have to actually render). `SymbolGlyphView.sizeThatFits` honours *any* + // explicit finite width proposal, which is right for a caller that wants to constrain it — + // but this row's own `HStack` sits under an outer `.frame(maxWidth: .infinity)`, and + // without `.fixedSize()` here the row can propose more than the control's intrinsic width + // once there is slack to give it, silently drowning the 5:4 ratio the owner asked for. + // `.fixedSize()` makes SwiftUI always ask for the ideal size instead. + .fixedSize() } } @@ -206,6 +214,10 @@ struct CardStyleSection: View { onChange: { commitBackground($0) }, onPanelChange: { applyPanelBackground($0) } ) + // `symbolColumn`'s own fix, restated: pins this rectangle to its intrinsic 6:4 size + // rather than whatever width the row's outer `.frame(maxWidth: .infinity)` leaves slack + // for `ColorSwatchView.sizeThatFits` to honour. + .fixedSize() } } diff --git a/Kanban/UI/ColorSwatchPicker.swift b/Kanban/UI/ColorSwatchPicker.swift index 3786eb5..9d93976 100644 --- a/Kanban/UI/ColorSwatchPicker.swift +++ b/Kanban/UI/ColorSwatchPicker.swift @@ -170,7 +170,7 @@ private struct ColorSwatchView: NSViewRepresentable { } func updateNSView(_ control: ColorSwatchControl, context: Context) { - control.metrics = .current + control.metrics = .current(.color) control.isEnabled = isEnabled control.swatchValue = value control.accessibilityValueText = value ?? "None" @@ -208,17 +208,22 @@ final class ColorSwatchControl: PickerRectControl { /// The colour rect: a `textBackgroundColor` underlay so a translucent stored colour composites /// the same way in light and dark, the resolved colour on top, a `separatorColor` hairline - /// stroke last. `nil`/unresolvable value → underlay + stroke only, the same "there is no colour, - /// so show none" rule the retired combo's face drew — straight into the control's own graphics - /// context, exactly as before, rather than through `PaletteSwatch.rectImage`'s intermediate - /// `NSImage` (that function had exactly one caller, the retired dropdown's menu rows, and is - /// gone with it). + /// stroke last. `nil`/unresolvable value → underlay + stroke **plus the popover well's own + /// corner-to-corner slash** — the owner's 2026-08-10 follow-up ("when 'none' selected the + /// strike-through line should show on the selected color too"): the collapsed face is itself + /// showing the current value, so a value that resolves to "no colour" gets the identical None + /// treatment the grid's own leading well draws, rather than a plain empty rectangle — straight + /// into the control's own graphics context, exactly as the fill was before, rather than through + /// `PaletteSwatch.rectImage`'s intermediate `NSImage` (that function had exactly one caller, the + /// retired dropdown's menu rows, and is gone with it). override func drawFace(in rect: NSRect) { guard rect.width > 0, rect.height > 0 else { return } let path = NSBezierPath(roundedRect: rect, xRadius: metrics.cornerRadius, yRadius: metrics.cornerRadius) NSColor.textBackgroundColor.setFill() path.fill() - if let swatchValue, let color = Palette.nsColor(for: swatchValue) { + if Self.showsNoneStrike(for: swatchValue) { + drawNoneStrike(in: rect) + } else if let color = swatchValue.flatMap(Palette.nsColor(for:)) { color.setFill() path.fill() } @@ -226,6 +231,36 @@ final class ColorSwatchControl: PickerRectControl { path.lineWidth = 1 path.stroke() } + + /// Whether the face has nothing to fill and should draw the None strike instead — `nil`, and any + /// stored value this app cannot resolve to a colour, both read the same lenient way + /// `StyleWellFace.Face.color`'s own doc already states it for the grid's wells ("An unresolvable + /// value draws like `noValue`"). A pure static predicate, not inlined into `drawFace`, so the rule + /// is assertable without an actual draw — the same pattern `SymbolGlyphControl`'s sizing statics + /// already use. + static func showsNoneStrike(for value: String?) -> Bool { + value.flatMap(Palette.nsColor(for:)) == nil + } + + /// Strokes `ColorSwatchNoneStrike`'s own corner-to-corner slash (below) across `rect` — the + /// popover well's exact geometry, reused rather than restated, so the grid's None well and this + /// collapsed face agree pixel for pixel about what "no colour" looks like. `ColorSwatchNoneStrike` + /// is a plain SwiftUI `Shape`; its `path(in:)` is pure geometry with no SwiftUI environment or + /// rendering context of its own, so calling it here and stroking the result straight into this + /// control's AppKit graphics context is exactly as valid as the popover calling it inside + /// `.stroke(...)`. The inset reads off `min(rect.width, rect.height)` rather than `layout. + /// wellSide` — the well grid's own wells are square, this face is not (6:4/5:4), so the shorter + /// side stands in for it. + private func drawNoneStrike(in rect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + let inset = max(1, (min(rect.width, rect.height) * 0.15).rounded()) + context.saveGState() + context.addPath(ColorSwatchNoneStrike(inset: inset).path(in: rect).cgPath) + context.setStrokeColor(NSColor.secondaryLabelColor.cgColor) + context.setLineWidth(1) + context.strokePath() + context.restoreGState() + } } // MARK: - The popover's content diff --git a/Kanban/UI/PickerRect.swift b/Kanban/UI/PickerRect.swift index a275ed8..276fd9a 100644 --- a/Kanban/UI/PickerRect.swift +++ b/Kanban/UI/PickerRect.swift @@ -22,17 +22,28 @@ import AppKit /// ### Sizing (the same ruling): "50% taller... and about 4:6 ratio of height to width" /// /// `height` is **2.1 em** — 50% over the two-zone chrome's last-shipped 1.4 em (18pt → 27pt at the -/// standard 13pt body). `width` is `height` **times 1.5** — the 4:6 ratio stated as a multiplier on -/// `height` rather than as its own independent em figure, so the ratio holds exactly (to rounding) at +/// standard 13pt body). `width` is `height` **times a per-role ratio**, stated as a multiplier on +/// `height` rather than as its own independent em figure, so each ratio holds exactly (to rounding) at /// every body size instead of two multiples that could drift apart the way `ComboFieldMetrics`' /// `height` and `width` once did (that file's own retired header told that story). 10-accessibility.md's /// full-relative-scaling rule, unchanged: every figure is a multiple of the body point size. +/// +/// ### Two widths, one height (the owner's 2026-08-10 follow-up, verbatim) +/// +/// "width to height ratio should be closer to 6:4 [for color] ... the symbol picker should be even +/// more square at ratio of 5:4 or so ... the height for the two pickers should be exactly the same." +/// `height` stays a single shared figure — nothing here lets it diverge between the two controls — +/// while `width` becomes a function of which picker is asking, via `WidthRatio`. The ratio lives as a +/// multiplier rather than two hand-rounded em widths for the reason above: two independent em figures +/// can drift apart a rounding step at a time as the body size changes, the exact failure this file's +/// own `height`/`width` split was already written to avoid for the first ratio. struct PickerRectMetrics: Equatable { - /// The control's height — 2.1 em, 27pt at the standard 13pt body. + /// The control's height — 2.1 em, 27pt at the standard 13pt body. **Identical between the two + /// pickers by the owner's own ruling**; nothing in this type lets a caller diverge it by role. var height: CGFloat - /// The control's width — `height × 1.5`, so the 4:6 ratio is exact rather than approximated by - /// two independently rounded em figures. + /// The control's width — `height × widthRatio`, so the requested ratio is exact rather than + /// approximated by two independently rounded em figures. var width: CGFloat /// The rectangle's own radius, and the field's — a point apart so the two rounded rects run /// concentric. Unchanged from the two-zone chrome's own figures; nothing about the trigger's @@ -40,10 +51,20 @@ struct PickerRectMetrics: Equatable { var cornerRadius: CGFloat var fieldRadius: CGFloat - static func metrics(bodyPointSize: CGFloat) -> PickerRectMetrics { + /// The width:height multiplier a picker asks for — a closed set of two named cases (not a bare + /// `CGFloat` parameter) so a call site reads "the colour picker's ratio" rather than a literal + /// `1.5` a reader has to trust is still current. + enum WidthRatio: CGFloat, Equatable { + /// The colour rectangle — "closer to 6:4" (width:height), the owner's 2026-08-10 follow-up. + case color = 1.5 + /// The symbol rectangle — "even more square... ratio of 5:4 or so", the same follow-up. + case symbol = 1.25 + } + + static func metrics(bodyPointSize: CGFloat, widthRatio: WidthRatio) -> PickerRectMetrics { func em(_ multiple: CGFloat) -> CGFloat { max(1, (bodyPointSize * multiple).rounded()) } let height = em(2.1) - let width = max(1, (height * 1.5).rounded()) + let width = max(1, (height * widthRatio.rawValue).rounded()) return PickerRectMetrics( height: height, width: width, @@ -52,11 +73,13 @@ struct PickerRectMetrics: Equatable { ) } - /// The live metrics — read from the same place every other font-derived geometry in the app - /// reads it (`CardWindowMetrics.bodyPointSize`), so a text-size change moves both rectangles with - /// everything else. + /// The live metrics for one picker's role — read from the same place every other font-derived + /// geometry in the app reads it (`CardWindowMetrics.bodyPointSize`), so a text-size change moves + /// both rectangles, and both stay the same height, with everything else. @MainActor - static var current: PickerRectMetrics { metrics(bodyPointSize: CardWindowMetrics.bodyPointSize) } + static func current(_ widthRatio: WidthRatio) -> PickerRectMetrics { + metrics(bodyPointSize: CardWindowMetrics.bodyPointSize, widthRatio: widthRatio) + } } // MARK: - The control @@ -69,8 +92,10 @@ struct PickerRectMetrics: Equatable { class PickerRectControl: NSControl { /// The geometry every pass draws from, refreshed by the representable on each SwiftUI update so - /// a text-size change lands without recreating the view. - var metrics: PickerRectMetrics = .metrics(bodyPointSize: 13) { + /// a text-size change lands without recreating the view. The placeholder ratio here is arbitrary + /// — every real subclass instance gets its own role's metrics on the first `updateNSView` before + /// this default is ever drawn or measured. + var metrics: PickerRectMetrics = .metrics(bodyPointSize: 13, widthRatio: .color) { didSet { guard metrics != oldValue else { return } invalidateIntrinsicContentSize() diff --git a/Kanban/UI/SymbolPicker.swift b/Kanban/UI/SymbolPicker.swift index c0aef00..c5cd7b6 100644 --- a/Kanban/UI/SymbolPicker.swift +++ b/Kanban/UI/SymbolPicker.swift @@ -374,7 +374,7 @@ private struct SymbolGlyphView: NSViewRepresentable { } func updateNSView(_ control: SymbolGlyphControl, context: Context) { - control.metrics = .current + control.metrics = .current(.symbol) control.isEnabled = isEnabled control.glyphName = resolvedName control.glyphTint = currentColor.flatMap(Palette.nsColor(for:)) @@ -441,15 +441,26 @@ final class SymbolGlyphControl: PickerRectControl { /// so the fix reads the point size off the rect the face actually receives, via `glyphPointSize( /// forFace:)` and `fittedSize(for:in:)` below — pure functions, not inlined here, so the sizing /// rule is assertable without an actual draw. + /// + /// **A sliver of padding returns, on purpose, in the 2026-08-10 follow-up.** The edge-to-edge + /// draw above went further than the owner's original "reduce padding" asked, once the rectangle + /// itself was squared up — a glyph with no breath at all reads as clipped rather than centred. Fix + /// is a small em-derived inset (`glyphInset(bodyPointSize:)`) subtracted from the face rect + /// *before* sizing and fitting — not a fixed pixel margin, so it scales with the rest of the + /// control's font-derived geometry, and not re-added to the drawn image's position, since the + /// inset rect is centred on the same midpoint as the uninset one. override func drawFace(in rect: NSRect) { guard rect.width > 0, rect.height > 0 else { return } + let inset = Self.glyphInset(bodyPointSize: CardWindowMetrics.bodyPointSize) + let faceRect = rect.insetBy(dx: inset, dy: inset) + guard faceRect.width > 0, faceRect.height > 0 else { return } let name = ItemSymbol.exists(glyphName) ? glyphName : "questionmark.square.dashed" - let config = NSImage.SymbolConfiguration(pointSize: Self.glyphPointSize(forFace: rect), weight: .regular) + let config = NSImage.SymbolConfiguration(pointSize: Self.glyphPointSize(forFace: faceRect), weight: .regular) .applying(.init(paletteColors: [glyphTint ?? .labelColor])) guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)? .withSymbolConfiguration(config) else { return } - let size = Self.fittedSize(for: image.size, in: rect) + let size = Self.fittedSize(for: image.size, in: faceRect) image.draw(in: NSRect( x: rect.midX - size.width / 2, y: rect.midY - size.height / 2, @@ -458,14 +469,26 @@ final class SymbolGlyphControl: PickerRectControl { )) } - /// The glyph's target point size for a face rect — no inset subtracted, so this is simply whichever - /// of the rect's own two dimensions is smaller. SF Symbols already carry their own internal - /// margins, and the owner's evening review asked for the *dead space around* the glyph gone, not a - /// second margin layered on top of the system's own. + /// The glyph's target point size for a face rect — no inset subtracted here (the caller already + /// applied `glyphInset(bodyPointSize:)` to `rect` before handing it over), so this is simply + /// whichever of the rect's own two dimensions is smaller. SF Symbols already carry their own + /// internal margins, and the owner's evening review asked for the *dead space around* the glyph + /// gone, not a second margin layered on top of the system's own — the 2026-08-10 sliver is that + /// second margin reintroduced deliberately, at a much smaller, explicit figure. static func glyphPointSize(forFace rect: NSRect) -> CGFloat { min(rect.width, rect.height) } + /// **"A sliver of padding around the symbol"** (owner's 2026-08-10 follow-up) — a small, visible + /// breath between the glyph and the face's own edge/border, not a ring wide enough to shrink the + /// glyph noticeably. `0.18` em sits at the middle of the owner's named 0.15–0.2 range, and — like + /// every other figure on this control — is a multiple of the body point size rather than a flat + /// pixel constant (10-accessibility.md's full-relative-scaling rule). A pure static function, not + /// inlined into `drawFace`, so the figure is assertable without an actual draw. + static func glyphInset(bodyPointSize: CGFloat) -> CGFloat { + max(1, (bodyPointSize * 0.18).rounded()) + } + /// `imageSize` unchanged, unless it overshoots `rect` on either axis — a symbol configured at /// `glyphPointSize(forFace:)` can still render wider (or, rarely, taller) than that on its long /// axis, since SF Symbols are not all square glyphs. Scaled down proportionally so the drawn glyph diff --git a/KanbanTests/ColorSwatchPickerTests.swift b/KanbanTests/ColorSwatchPickerTests.swift index fd1459e..d3ef226 100644 --- a/KanbanTests/ColorSwatchPickerTests.swift +++ b/KanbanTests/ColorSwatchPickerTests.swift @@ -88,3 +88,35 @@ struct ColorSwatchPopoverLayoutTests { #expect(layout.popoverWidth == layout.gridWidth + layout.contentPadding * 2) } } + +// MARK: - The collapsed face's None treatment + +/// **The owner's 2026-08-10 follow-up, verbatim**: "when 'none' selected the strike-through line +/// should show on the selected color too." `ColorSwatchControl.showsNoneStrike(for:)` is the pure +/// predicate behind that draw — the same lenient "nil or unresolvable both read as no colour" rule +/// `StyleWellFace.Face.color`'s own doc already states for the popover's grid, restated here for the +/// collapsed face so the two surfaces cannot drift apart about what counts as "None." +@Suite("ColorSwatchControl ▸ the None face") +struct ColorSwatchControlNoneFaceTests { + + @Test("A nil value shows the None strike") + func nilShowsTheStrike() { + #expect(ColorSwatchControl.showsNoneStrike(for: nil)) + } + + @Test("A resolvable palette name does not show the strike") + func resolvableNameHidesTheStrike() { + let name = Palette.backgrounds[0].name + #expect(!ColorSwatchControl.showsNoneStrike(for: name)) + } + + @Test("A resolvable hand-written hex does not show the strike") + func resolvableHexHidesTheStrike() { + #expect(!ColorSwatchControl.showsNoneStrike(for: "#336699")) + } + + @Test("An unresolvable stored value shows the strike, the same lenient rule the popover's grid uses") + func unresolvableValueShowsTheStrike() { + #expect(ColorSwatchControl.showsNoneStrike(for: "not-a-real-palette-name")) + } +} diff --git a/KanbanTests/SymbolCatalogTests.swift b/KanbanTests/SymbolCatalogTests.swift index db88e8e..76f56fc 100644 --- a/KanbanTests/SymbolCatalogTests.swift +++ b/KanbanTests/SymbolCatalogTests.swift @@ -214,57 +214,93 @@ struct SymbolCatalogSearchTests { // MARK: - The shared rectangle chrome -/// **The rhyme, asserted.** The owner's 2026-08-10 ruling asked for both pickers to become "a -/// rectangle (slightly oversized)... about 4:6 ratio of height to width" — and the way that is made -/// true is structural — one metrics value, one base control — so the test is about the structure -/// rather than about two numbers that happen to agree today. +/// **The rhyme, asserted — now with two widths.** The owner's 2026-08-10 ruling first asked for both +/// pickers to become "a rectangle (slightly oversized)... about 4:6 ratio of height to width", then a +/// same-day follow-up refined it per role: "width to height ratio should be closer to 6:4 [colour] ... +/// the symbol picker should be even more square at ratio of 5:4 or so ... the height for the two +/// pickers should be exactly the same." The way both halves of that are made true is structural — one +/// metrics value, one base control, one shared `height`, a `WidthRatio` that only ever changes +/// `width` — so the tests are about the structure rather than about numbers that happen to agree today. @Suite("PickerRect ▸ the shared chrome") struct PickerRectMetricsTests { @Test("Every figure scales with the body font, and reproduces the shipped numbers at 13pt") func metricsAtTheStandardBody() { - let metrics = PickerRectMetrics.metrics(bodyPointSize: 13) + let color = PickerRectMetrics.metrics(bodyPointSize: 13, widthRatio: .color) + let symbol = PickerRectMetrics.metrics(bodyPointSize: 13, widthRatio: .symbol) // Owner's ruling, 2026-08-10: "50% taller" than the retired two-zone chrome's 18pt (→ 27pt), - // and "about 4:6 ratio" — width is height × 1.5 (`PickerRectMetrics`'s own doc comment). - #expect(metrics.height == 27) - #expect(metrics.width == 41) - #expect(metrics.cornerRadius == 3) - #expect(metrics.fieldRadius == 4) + // shared by both pickers — and each picker's own width ratio from the same day's follow-up: + // 6:4 (× 1.5) for colour, 5:4 (× 1.25) for symbol. + #expect(color.height == 27) + #expect(symbol.height == 27) + #expect(color.width == 41) + #expect(symbol.width == 34) + for metrics in [color, symbol] { + #expect(metrics.cornerRadius == 3) + #expect(metrics.fieldRadius == 4) + } } - @Test("A larger text size grows every figure, and none collapses to zero") - func metricsScale() { - let small = PickerRectMetrics.metrics(bodyPointSize: 11) - let large = PickerRectMetrics.metrics(bodyPointSize: 24) + @Test("A larger text size grows every figure, and none collapses to zero", arguments: [ + PickerRectMetrics.WidthRatio.color, .symbol, + ]) + func metricsScale(widthRatio: PickerRectMetrics.WidthRatio) { + let small = PickerRectMetrics.metrics(bodyPointSize: 11, widthRatio: widthRatio) + let large = PickerRectMetrics.metrics(bodyPointSize: 24, widthRatio: widthRatio) #expect(large.height > small.height) #expect(large.width > small.width) - for metrics in [PickerRectMetrics.metrics(bodyPointSize: 8), small, large] { + for metrics in [PickerRectMetrics.metrics(bodyPointSize: 8, widthRatio: widthRatio), small, large] { #expect(metrics.height >= 1) #expect(metrics.width >= 1) #expect(metrics.cornerRadius >= 1) } } - /// **The owner's 2026-08-10 figures, pinned.** Height is 2.1 em; width is derived from height - /// (`height × 1.5`) rather than its own independent em multiple, which is what keeps the 4:6 - /// ratio exact — to rounding — at every body size instead of the two figures drifting apart. - @Test("Height is 2.1 em, width is 1.5 × height, at every body size") + /// **The owner's 2026-08-10 figures, pinned, per role.** Height is 2.1 em for both pickers alike; + /// width is derived from height (`height × widthRatio`) rather than its own independent em + /// multiple, which is what keeps each ratio exact — to rounding — at every body size instead of + /// the two figures drifting apart. + @Test("Height is 2.1 em; width is 1.5 × height for colour, 1.25 × height for symbol, at every body size") func heightAndWidthHoldTheRatio() { for size in [8.0, 11.0, 13.0, 17.0, 24.0, 36.0] as [CGFloat] { - let metrics = PickerRectMetrics.metrics(bodyPointSize: size) - #expect(metrics.height == max(1, (size * 2.1).rounded()), "height drifted from 2.1 em at \(size)pt") - #expect(metrics.width == max(1, (metrics.height * 1.5).rounded()), "width drifted from 1.5 × height at \(size)pt") - // 4:6 as a ratio, within the slack one rounding step introduces at the smallest sizes. - #expect(abs(metrics.width / metrics.height - 1.5) < 0.06, "ratio drifted from 4:6 at \(size)pt") + for widthRatio in [PickerRectMetrics.WidthRatio.color, .symbol] { + let metrics = PickerRectMetrics.metrics(bodyPointSize: size, widthRatio: widthRatio) + #expect(metrics.height == max(1, (size * 2.1).rounded()), "height drifted from 2.1 em at \(size)pt (\(widthRatio))") + #expect( + metrics.width == max(1, (metrics.height * widthRatio.rawValue).rounded()), + "width drifted from \(widthRatio.rawValue) × height at \(size)pt" + ) + // Within the slack one rounding step introduces at the smallest sizes. + #expect( + abs(metrics.width / metrics.height - widthRatio.rawValue) < 0.06, + "ratio drifted from \(widthRatio.rawValue) at \(size)pt" + ) + } + } + } + + /// **The owner's most literal ask, pinned on its own**: "the height for the two pickers should be + /// exactly the same" — not merely close, and not merely equal at 13pt, but equal at every body + /// size, since `height` never reads `widthRatio` at all. + @Test("The two pickers share exactly the same height at every body size") + func heightsMatchAcrossRolesAtEverySize() { + for size in [8.0, 11.0, 13.0, 17.0, 24.0, 36.0] as [CGFloat] { + let color = PickerRectMetrics.metrics(bodyPointSize: size, widthRatio: .color) + let symbol = PickerRectMetrics.metrics(bodyPointSize: size, widthRatio: .symbol) + #expect(color.height == symbol.height, "heights diverged at \(size)pt") + // And the widths must genuinely differ — the symbol picker is "even more square" than + // the colour rectangle, not merely renamed to a ratio that happens to compute the same. + #expect(symbol.width < color.width, "the symbol picker is not narrower than the colour picker at \(size)pt") } } /// The field radius runs a point outside the face's so the two rounded rects stay concentric — - /// a small thing, and exactly the kind of thing that drifts when two files own it. + /// a small thing, and exactly the kind of thing that drifts when two files own it. Ratio- + /// independent (neither radius reads `widthRatio`), so one role stands in for both. @Test("The field's radius stays outside the face's") func radiiAreConcentric() { for size in [11.0, 13.0, 17.0, 24.0] as [CGFloat] { - let metrics = PickerRectMetrics.metrics(bodyPointSize: size) + let metrics = PickerRectMetrics.metrics(bodyPointSize: size, widthRatio: .color) #expect(metrics.fieldRadius >= metrics.cornerRadius) } } @@ -301,20 +337,49 @@ struct PickerRectMetricsTests { "the scale-down must preserve the glyph's own aspect ratio") } - /// **The two controls are the same control.** Both are `PickerRectControl`s and both take their - /// geometry from the same value, so a change to one lands on the other — which is the whole of - /// the parity claim, and cheaper to assert than any pair of measurements. There are no zones left - /// to compare (`PickerRect.swift`'s own retirement of the trigger strip); the whole bounds is the - /// one hit zone on both, so intrinsic size is the whole of what "same chrome" means now. + /// **"A sliver of padding around the symbol"** (owner's 2026-08-10 follow-up) — a pure static + /// figure, so the rule (roughly 0.15–0.2 em, never zero) is assertable without a draw. + @Test("The glyph's inset scales with the body font, sits in the owner's named range, and never zeroes out") + func glyphInsetHoldsTheOwnersRange() { + var previous: CGFloat = 0 + for size in [8.0, 11.0, 13.0, 17.0, 24.0, 36.0] as [CGFloat] { + let inset = SymbolGlyphControl.glyphInset(bodyPointSize: size) + #expect(inset >= 1, "the inset collapsed to nothing at \(size)pt") + #expect(inset >= previous, "the inset shrank as the body font grew, at \(size)pt") + previous = inset + // Within the slack one rounding step introduces at the smallest sizes. + #expect((0.15 - 0.03...0.2 + 0.03).contains(inset / size), "inset drifted outside ~0.15–0.2 em at \(size)pt") + } + } + + /// A glyph inset by `glyphInset(bodyPointSize:)` genuinely narrows the rect `glyphPointSize( + /// forFace:)`/`fittedSize(for:in:)` size against — the sliver has to actually shrink the drawn + /// glyph, not merely exist as an unused figure. + @Test("An inset face rect yields a smaller point size than the same rect uninset") + func insetFaceRectShrinksTheGlyph() { + let rect = NSRect(x: 0, y: 0, width: 34, height: 27) + let inset = SymbolGlyphControl.glyphInset(bodyPointSize: 13) + let insetRect = rect.insetBy(dx: inset, dy: inset) + #expect(SymbolGlyphControl.glyphPointSize(forFace: insetRect) < SymbolGlyphControl.glyphPointSize(forFace: rect)) + } + + /// **The two controls are the same control, at two different widths.** Both are + /// `PickerRectControl`s and both take their geometry from the same value, so a change to one lands + /// on the other — which is the whole of the parity claim, and cheaper to assert than any pair of + /// measurements. There are no zones left to compare (`PickerRect.swift`'s own retirement of the + /// trigger strip); the whole bounds is the one hit zone on both. Height is the one figure that + /// still has to match exactly (the owner's 2026-08-10 follow-up); width now diverges by role on + /// purpose, so "same chrome" means equal height and each control's own ratio-derived width. @MainActor - @Test("Both rectangles are the same chrome, at the same size") + @Test("Both rectangles are the same chrome — equal height, each its own ratio's width") func bothRectanglesShareTheChrome() { let colour = ColorSwatchControl(frame: .zero) let symbol = SymbolGlyphControl(frame: .zero) - for control in [colour as PickerRectControl, symbol] { - control.metrics = .metrics(bodyPointSize: 13) - } + colour.metrics = .metrics(bodyPointSize: 13, widthRatio: .color) + symbol.metrics = .metrics(bodyPointSize: 13, widthRatio: .symbol) #expect(colour.intrinsicContentSize.height == symbol.intrinsicContentSize.height) - #expect(colour.intrinsicContentSize.width == symbol.intrinsicContentSize.width) + #expect(colour.intrinsicContentSize.width == 41) + #expect(symbol.intrinsicContentSize.width == 34) + #expect(symbol.intrinsicContentSize.width < colour.intrinsicContentSize.width) } }