Two widths, one height — the symbol picker squares to 5:4, colour holds 6:4, and None's slash follows the swatch out of the grid

The owner's follow-up review on the shipped rectangles (Pipeline card
5004c540): height was close, so it stays exactly 2.1em on both controls;
width now diverges by role via PickerRectMetrics.WidthRatio (color 1.5,
symbol 1.25) instead of one shared multiplier. The symbol glyph gets a
small em-derived inset back (SymbolGlyphControl.glyphInset) — a sliver of
breath, not the old padded ring — applied to the face rect before sizing
and fitting, still centred on the same midpoint. The collapsed colour
swatch reuses ColorSwatchNoneStrike's own geometry to draw the popover's
None slash whenever the stored value is nil or unresolvable, rather than
sitting empty. Both mounting anchors (the card sidebar's side-by-side
columns, the board popover beside the rename field) pick up .fixedSize()
so the controls render at their own intrinsic size instead of stretching
into whatever slack an HStack proposal leaves them.

KanbanTests/SymbolCatalogTests.swift and ColorSwatchPickerTests.swift
updated for the per-role widths, the shared-height claim, the glyph
inset rule, and the None-face predicate.
This commit is contained in:
2026-08-09 21:37:29 -04:00
parent ac4f8b9050
commit f191e5c3ce
7 changed files with 264 additions and 63 deletions
+9
View File
@@ -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)
}
}
+12
View File
@@ -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()
}
}
+42 -7
View File
@@ -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
+38 -13
View File
@@ -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()
+30 -7
View File
@@ -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.150.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
+32
View File
@@ -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"))
}
}
+101 -36
View File
@@ -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.150.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.150.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)
}
}