The combo's trigger retires — each picker is one rectangle again, taller, and Style becomes Appearance in the sidebar
Both the symbol picker and the background color control give up the two-zone combo chrome from the 2026-08-09 rework: no more face/trigger split, no more trailing chevron square. Each is now a single bordered rectangle with one hit zone, 2.1em tall and 1.5x that wide (a 4:6 ratio, 50% taller than the retired chrome's 18pt). A click anywhere opens the same curated popover the trigger used to gate. The background rectangle's popover is new: a None-plus-sixteen palette grid mirroring the Style… popover's own wells, with an Other… row onto the shared, pre-debounced Colors panel session — replacing the old NSMenu dropdown outright. The symbol rectangle keeps its existing popover (search, curated grid, tint colors, More Symbols…) verbatim; only its entry point collapsed to one zone. ComboFieldControl/ComboFieldMetrics (ComboField.swift) are replaced by PickerRectControl/PickerRectMetrics (PickerRect.swift). ColorComboView and its NSMenu-building pure model are retired wholesale in favor of ColorSwatchPicker. SymbolComboControl becomes SymbolGlyphControl. PaletteSwatch.rectImage, the last caller of which was the retired dropdown's menu rows, goes with it. In the card window sidebar, the "Style" section header becomes "Appearance" (sidebar only — the board context menu's Style… item and StyleEditorView's own naming are untouched), and the symbol and background controls move from stacked rows to side-by-side columns, each captioned above rather than leading. CardStyleSection no longer carries its own debounce Task for background panel picks — the shared Colors-panel session now delivers an already-settled value. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -212,55 +212,50 @@ struct SymbolCatalogSearchTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The shared combo chrome
|
||||
// MARK: - The shared rectangle chrome
|
||||
|
||||
/// **The rhyme, asserted.** The card's first ask was that the two pickers be "roughly same
|
||||
/// shape/size", and the way that was 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.
|
||||
@Suite("ComboField ▸ the shared chrome")
|
||||
struct ComboFieldMetricsTests {
|
||||
/// **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.
|
||||
@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 = ComboFieldMetrics.metrics(bodyPointSize: 13)
|
||||
// Evening 2026-08-09 review: "reduce vertical size of both pickers by 50%" — height is half
|
||||
// the 4:5 pass's 36, and width holds the 4:5 pass's own pixel figure instead of re-deriving
|
||||
// from the now-shorter height (`ComboFieldMetrics`'s own doc comment).
|
||||
#expect(metrics.height == 18)
|
||||
#expect(metrics.width == 29)
|
||||
#expect(metrics.triggerWidth == 16)
|
||||
let metrics = PickerRectMetrics.metrics(bodyPointSize: 13)
|
||||
// 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)
|
||||
#expect(metrics.triggerInset == 2)
|
||||
}
|
||||
|
||||
@Test("A larger text size grows every figure, and none collapses to zero")
|
||||
func metricsScale() {
|
||||
let small = ComboFieldMetrics.metrics(bodyPointSize: 11)
|
||||
let large = ComboFieldMetrics.metrics(bodyPointSize: 24)
|
||||
let small = PickerRectMetrics.metrics(bodyPointSize: 11)
|
||||
let large = PickerRectMetrics.metrics(bodyPointSize: 24)
|
||||
#expect(large.height > small.height)
|
||||
#expect(large.width > small.width)
|
||||
#expect(large.triggerWidth > small.triggerWidth)
|
||||
for metrics in [ComboFieldMetrics.metrics(bodyPointSize: 8), small, large] {
|
||||
for metrics in [PickerRectMetrics.metrics(bodyPointSize: 8), small, large] {
|
||||
#expect(metrics.height >= 1)
|
||||
#expect(metrics.width >= 1)
|
||||
#expect(metrics.triggerWidth >= 1)
|
||||
#expect(metrics.cornerRadius >= 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The owner's evening figures, held still.** "Reduce vertical size of both pickers by 50%"
|
||||
/// (2026-08-09 evening) superseded the earlier "almost square, about 4:5 ratio" ruling outright —
|
||||
/// `height` and `width` are now independent em figures rather than one derived from the other, so
|
||||
/// this asserts both hold at every body size the superseded ratio test used, and that `faceWidth`
|
||||
/// never collapses the way it would have if `width` had stayed a function of `height`.
|
||||
@Test("Height is 1.4 em, width is 2.2 em, and the face zone stays positive at every body size")
|
||||
func heightAndWidthAreIndependentEmFigures() {
|
||||
/// **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")
|
||||
func heightAndWidthHoldTheRatio() {
|
||||
for size in [8.0, 11.0, 13.0, 17.0, 24.0, 36.0] as [CGFloat] {
|
||||
let metrics = ComboFieldMetrics.metrics(bodyPointSize: size)
|
||||
#expect(metrics.height == max(1, (size * 1.4).rounded()), "height drifted from 1.4 em at \(size)pt")
|
||||
#expect(metrics.width == max(1, (size * 2.2).rounded()), "width drifted from 2.2 em at \(size)pt")
|
||||
#expect(metrics.faceWidth > 0, "the face zone collapsed at \(size)pt")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,80 +264,57 @@ struct ComboFieldMetricsTests {
|
||||
@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 = ComboFieldMetrics.metrics(bodyPointSize: size)
|
||||
let metrics = PickerRectMetrics.metrics(bodyPointSize: size)
|
||||
#expect(metrics.fieldRadius >= metrics.cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The new actual-rect rule.** `ComboFieldMetrics.glyphPointSize` is gone; `SymbolComboControl`
|
||||
/// now sizes a glyph off whichever face rect it is actually handed at draw time (this is what lets
|
||||
/// the sidebar's stretched-wide control still read as "no padding" rather than a small glyph in a
|
||||
/// big field). Both the point-size rule and the overshoot-scaling rule are pure static functions
|
||||
/// on `SymbolComboControl`, assertable with no control on screen and no draw.
|
||||
/// **The actual-rect rule, unaffected by the trigger's removal.** `SymbolGlyphControl` sizes a
|
||||
/// glyph off whichever face rect it is actually handed at draw time — now always the control's
|
||||
/// own `bounds`, since there is no trigger strip left to subtract. Both the point-size rule and
|
||||
/// the overshoot-scaling rule are pure static functions on `SymbolGlyphControl`, assertable with
|
||||
/// no control on screen and no draw.
|
||||
@Test("A glyph's point size and drawn size follow the actual face rect, not the pure metrics")
|
||||
func glyphSizesOffTheActualFaceRect() {
|
||||
// A short, wide rect — the shape the card sidebar's row actually proposes now that the field
|
||||
// is wider than it is tall. The binding dimension is height, not width.
|
||||
let wideRect = NSRect(x: 0, y: 0, width: 200, height: 18)
|
||||
#expect(SymbolComboControl.glyphPointSize(forFace: wideRect) == 18)
|
||||
let wideRect = NSRect(x: 0, y: 0, width: 200, height: 27)
|
||||
#expect(SymbolGlyphControl.glyphPointSize(forFace: wideRect) == 27)
|
||||
|
||||
// A tall, narrow rect, for symmetry — the binding dimension flips to width.
|
||||
let tallRect = NSRect(x: 0, y: 0, width: 12, height: 40)
|
||||
#expect(SymbolComboControl.glyphPointSize(forFace: tallRect) == 12)
|
||||
#expect(SymbolGlyphControl.glyphPointSize(forFace: tallRect) == 12)
|
||||
|
||||
// A glyph configured within its rect never needs to grow.
|
||||
let snugSize = NSSize(width: 18, height: 18)
|
||||
#expect(SymbolComboControl.fittedSize(for: snugSize, in: wideRect) == snugSize)
|
||||
let snugSize = NSSize(width: 27, height: 27)
|
||||
#expect(SymbolGlyphControl.fittedSize(for: snugSize, in: wideRect) == snugSize)
|
||||
|
||||
// A horizontally elongated glyph (wider than the point size it was configured at) overshoots
|
||||
// the rect on its long axis and must be scaled down proportionally, not clipped.
|
||||
let elongated = NSSize(width: 36, height: 18)
|
||||
let fitted = SymbolComboControl.fittedSize(for: elongated, in: wideRect)
|
||||
#expect(fitted.width <= wideRect.width)
|
||||
#expect(fitted.height <= wideRect.height)
|
||||
// A glyph wider than the point size it was configured at genuinely overshoots a *narrow*
|
||||
// rect on its long axis (the realistic case: `tallRect`'s 12pt binding dimension, a symbol a
|
||||
// touch wider than tall at that size) and must be scaled down proportionally, not clipped.
|
||||
let elongated = NSSize(width: 16, height: 12)
|
||||
let fitted = SymbolGlyphControl.fittedSize(for: elongated, in: tallRect)
|
||||
#expect(fitted.width < elongated.width, "the wide axis must actually shrink")
|
||||
#expect(fitted.width <= tallRect.width)
|
||||
#expect(fitted.height <= tallRect.height)
|
||||
#expect(abs(fitted.width / fitted.height - elongated.width / elongated.height) < 0.001,
|
||||
"the scale-down must preserve the glyph's own aspect ratio")
|
||||
}
|
||||
|
||||
/// **The two controls are the same control.** Both are `ComboFieldControl`s and both take their
|
||||
/// **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.
|
||||
/// 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.
|
||||
@MainActor
|
||||
@Test("Both combos are the same chrome, at the same size")
|
||||
func bothCombosShareTheChrome() {
|
||||
let colour = ColorComboControl(frame: .zero)
|
||||
let symbol = SymbolComboControl(frame: .zero)
|
||||
for control in [colour as ComboFieldControl, symbol] {
|
||||
@Test("Both rectangles are the same chrome, at the same size")
|
||||
func bothRectanglesShareTheChrome() {
|
||||
let colour = ColorSwatchControl(frame: .zero)
|
||||
let symbol = SymbolGlyphControl(frame: .zero)
|
||||
for control in [colour as PickerRectControl, symbol] {
|
||||
control.metrics = .metrics(bodyPointSize: 13)
|
||||
}
|
||||
#expect(colour.intrinsicContentSize.height == symbol.intrinsicContentSize.height)
|
||||
#expect(colour.intrinsicContentSize.width == symbol.intrinsicContentSize.width)
|
||||
colour.setFrameSize(NSSize(width: 120, height: colour.intrinsicContentSize.height))
|
||||
symbol.setFrameSize(NSSize(width: 120, height: symbol.intrinsicContentSize.height))
|
||||
#expect(colour.triggerRect == symbol.triggerRect, "the trigger zones must line up")
|
||||
#expect(colour.faceZone == symbol.faceZone, "the face zones must line up")
|
||||
}
|
||||
|
||||
/// The two zones tile the control exactly — no dead strip between them, no overlap that would
|
||||
/// make one door swallow the other's clicks.
|
||||
@MainActor
|
||||
@Test("The face and the trigger tile the control with no gap and no overlap")
|
||||
func zonesTileTheControl() {
|
||||
let control = SymbolComboControl(frame: NSRect(x: 0, y: 0, width: 140, height: 18))
|
||||
control.metrics = .metrics(bodyPointSize: 13)
|
||||
#expect(control.faceZone.maxX == control.triggerRect.minX)
|
||||
#expect(control.faceZone.minX == control.bounds.minX)
|
||||
#expect(control.triggerRect.maxX == control.bounds.maxX)
|
||||
#expect(control.faceZone.width + control.triggerRect.width == control.bounds.width)
|
||||
}
|
||||
|
||||
/// A control too narrow for its own trigger must not hand the face a negative width — a sidebar
|
||||
/// squeezed to nothing is a layout bug, not a crash.
|
||||
@MainActor
|
||||
@Test("A control narrower than its trigger degrades to an empty face")
|
||||
func degenerateWidthIsSafe() {
|
||||
let control = SymbolComboControl(frame: NSRect(x: 0, y: 0, width: 4, height: 18))
|
||||
control.metrics = .metrics(bodyPointSize: 13)
|
||||
#expect(control.faceZone.width >= 0)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user