import AppKit import CoreGraphics /// The board strip's geometry — **derived from font metrics, never written down in points** /// (10-accessibility.md ▸ Text scaling & visual accommodations: "relative text styles everywhere, no /// fixed point sizes. Card face, lane header, and masonry metrics derive from font metrics, so /// layout survives the largest system text sizes"). /// /// This is `CardWindowMetrics`' twin on the board side, and deliberately the same shape: a pure /// arithmetic surface parameterised on the body font's point size, plus one impure read of what that /// point size currently is. Everything the strip draws that is not a piece of text — the inter-lane /// gap, a lane's plate inset, a card's corner radius and stripe, the masonry's card spacing, the /// trash column's header — is a multiple of that size, so the board grows with the system text size /// instead of squeezing text into chrome sized for 13pt. /// /// ### Why a point size and not a `Font` /// /// Because layout takes numbers. SwiftUI's relative text styles handle the *text* (and every `Text` /// on the board wears one — `.body`, `.headline`, `.caption`); what they cannot do is tell an /// `.padding()` how much room the text will need. So the two halves of "full relative scaling" are /// split by mechanism: type styles for glyphs, this surface for everything between them, both keyed /// to the same system font. /// /// ### The em, and what the multiples mean /// /// Every figure below is a multiple of the body point size — an *em*, roughly — chosen so that at /// the standard 13pt system body font it reproduces the numbers the board already drew. That is /// deliberate: this milestone is meant to make the board *scale*, not to redesign it, so the default /// text size must render pixel-for-pixel what it rendered before. The multiples are what carries the /// design to 18pt, 24pt and beyond. /// /// Results are rounded to whole points (SwiftUI will happily lay out on halves, but a hairline /// border on a half-point boundary blurs) and floored at 1 for anything that is a width or a height, /// so no proposal is ever zero or negative. /// /// ### The no-horizontal-scroll invariant is untouched /// /// 03-board-ui.md § Layout — full visibility divides the window's width across the lanes' width /// units, and `LaneLayoutMath` takes the gap as a parameter. A larger text size therefore means a /// larger gap and *narrower* lanes, never a wider strip: "the degenerate case is accepted, not /// floored … titles and cards truncate gracefully". The truncation rules are the views' own /// (`lineLimit(1)` + `.tail` on a lane title, `lineLimit(4)` on a card title), and they hold at every /// scale because they are stated in lines rather than in points. enum BoardMetrics { // MARK: - The unit /// `multiple` ems of the body font, rounded to a whole point and floored at one. /// /// Floored rather than clamped to zero because every caller is a length: a spacing of zero is a /// legitimate design choice, but none of the figures below is one, and a rounding that produced /// zero would silently collapse a stripe or a band rather than shrink it. static func em(_ multiple: CGFloat, bodyPointSize: CGFloat) -> CGFloat { max(1, (bodyPointSize * multiple).rounded()) } // MARK: - The strip /// The inter-lane gap **and** the strip's outer margin — one number, because the standard-width /// formula counts `units + 1` of them (`LaneLayoutMath.standardWidth`). /// /// 0.9 em: 12pt at the standard 13pt body, which is what the strip has always drawn. static func stripGap(bodyPointSize: CGFloat) -> CGFloat { em(0.9, bodyPointSize: bodyPointSize) } // MARK: - The lane /// The lane plate's corner radius — shared by the selection treatment and the accent band, whose /// top corners round to exactly this so the band reads as the lane's own edge. static func laneCornerRadius(bodyPointSize: CGFloat) -> CGFloat { em(0.75, bodyPointSize: bodyPointSize) } /// The lane plate's inset around its header and its masonry. static func lanePlatePadding(bodyPointSize: CGFloat) -> CGFloat { em(0.45, bodyPointSize: bodyPointSize) } /// Between the lane's header and its card stack. static func laneStackSpacing(bodyPointSize: CGFloat) -> CGFloat { em(0.6, bodyPointSize: bodyPointSize) } /// Between the header's glyph, its title and its count badge. static func laneHeaderSpacing(bodyPointSize: CGFloat) -> CGFloat { em(0.45, bodyPointSize: bodyPointSize) } /// The header row's own horizontal inset inside the plate. static func laneHeaderInset(bodyPointSize: CGFloat) -> CGFloat { em(0.3, bodyPointSize: bodyPointSize) } /// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities) — the lane accent band's /// height. static func laneAccentBandHeight(bodyPointSize: CGFloat) -> CGFloat { em(0.4, bodyPointSize: bodyPointSize) } /// The trailing room the header reserves for the new-card button, so **a long title truncates /// before it collides with the button** rather than running under it. /// /// This is the one figure that is not decoration: the button is an `Image` at /// `.imageScale(.small)`, which *is* a relative size, so a reserve fixed at 22pt would be /// overrun by the glyph itself at a large system text size and the truncation rule would stop /// being true. 1.7 em is the button plus its breathing room, measured in the same unit the glyph /// grows in. static func newCardButtonReserve(bodyPointSize: CGFloat) -> CGFloat { em(1.7, bodyPointSize: bodyPointSize) } /// The count badge's capsule inset — horizontal and vertical, which are deliberately different: /// a capsule around a single digit wants width, not height. static func badgeHorizontalPadding(bodyPointSize: CGFloat) -> CGFloat { em(0.45, bodyPointSize: bodyPointSize) } static func badgeVerticalPadding(bodyPointSize: CGFloat) -> CGFloat { em(0.08, bodyPointSize: bodyPointSize) } // MARK: - The card face /// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part /// of the card's edge rather than a bar laid over it. static func cardCornerRadius(bodyPointSize: CGFloat) -> CGFloat { em(0.6, bodyPointSize: bodyPointSize) } /// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities). Reserved as padding whether /// or not a stripe paints, so colouring a card never shifts its title. static func cardStripeWidth(bodyPointSize: CGFloat) -> CGFloat { em(0.3, bodyPointSize: bodyPointSize) } /// The card plate's inset around its content. static func cardContentPadding(bodyPointSize: CGFloat) -> CGFloat { em(0.75, bodyPointSize: bodyPointSize) } /// Between the icon, the title and the attachments chip. static func cardRowSpacing(bodyPointSize: CGFloat) -> CGFloat { em(0.45, bodyPointSize: bodyPointSize) } /// The masonry's spacing — between interior columns and between stacked cards within a column. /// /// The lane registers this into `LaneDropRegistry.Grid`, so the drop model's analytic resting /// grid replays the same number the layout drew with and no second derivation exists to drift /// (DRAG-REORDER.md § The card masonry). static func cardSpacing(bodyPointSize: CGFloat) -> CGFloat { em(0.6, bodyPointSize: bodyPointSize) } /// The height a card with no registered measurement is assumed to have — a lane whose faces have /// not laid out yet, and the shadow a Finder file drop opens for a card that does not exist. /// /// 3.4 em: one body line of title inside two content paddings, plus the plate's own rhythm. /// Nominal rather than zero, so the resting rows still tile. static func nominalCardHeight(bodyPointSize: CGFloat) -> CGFloat { em(3.4, bodyPointSize: bodyPointSize) } // MARK: - The drag replica /// The width the card drag's replica is drawn at: **the width of the face it was lifted from**. /// /// A face is as wide as the interior masonry column it sits in — a function of its lane's slot /// width and its lane's column count (`LaneLayoutMath`, `MasonryLayout`) — so it is not a figure /// this file can derive at all, only one the live face can report (`CardFaceView` measures it /// alongside the height the drop model already takes). Drawn at anything else, the image under /// the cursor is a card the board does not contain, and the pointer sits beside it rather than on /// it. /// /// `measured` is that width, and the fallback below is for the face that has not reported one /// yet. static func cardReplicaWidth(measured: CGFloat, bodyPointSize: CGFloat) -> CGFloat { guard measured.isFinite, measured > 0 else { return cardReplicaWidth(bodyPointSize: bodyPointSize) } return measured } /// The replica's fallback width — a card at a representative lane width, for the face that has /// not laid out yet and so has no width of its own to give. static func cardReplicaWidth(bodyPointSize: CGFloat) -> CGFloat { em(17, bodyPointSize: bodyPointSize) } /// The transparent margin around a drag replica, which is what keeps its shadow from being /// clipped by the drag image's bounds. static func replicaPadding(bodyPointSize: CGFloat) -> CGFloat { em(0.9, bodyPointSize: bodyPointSize) } /// The lane replica's floor dimensions, for the frame a lane that has not measured itself yet /// would otherwise be drawn at. static func laneReplicaMinimumWidth(bodyPointSize: CGFloat) -> CGFloat { em(6, bodyPointSize: bodyPointSize) } static func laneReplicaMinimumHeight(bodyPointSize: CGFloat) -> CGFloat { em(9, bodyPointSize: bodyPointSize) } // MARK: - The lane resize handle /// The invisible grab strip at a lane's trailing edge, and how far right it is shifted so most /// of it hangs into the inter-lane gap rather than sitting over the lane's own scrollbar /// (`LaneResizeHandle`). Both scale, because the gap they live in does. static func resizeHandleWidth(bodyPointSize: CGFloat) -> CGFloat { em(0.9, bodyPointSize: bodyPointSize) } static func resizeHandleOverhang(bodyPointSize: CGFloat) -> CGFloat { em(0.6, bodyPointSize: bodyPointSize) } // MARK: - The trash column /// The trash header's inset — horizontal and vertical (03-board-ui.md § Trash ▸ Rendering). static func trashHeaderHorizontalPadding(bodyPointSize: CGFloat) -> CGFloat { em(0.75, bodyPointSize: bodyPointSize) } static func trashHeaderVerticalPadding(bodyPointSize: CGFloat) -> CGFloat { em(0.6, bodyPointSize: bodyPointSize) } /// The gap between the trash header's diagonal hatch strokes. /// /// It scales for a reason the other figures do not share: the hatch is **the trash's non-colour /// distinction** (10-accessibility.md's never-colour-alone rule — "the trash header is hatched /// plus labeled"), and a fixed 7pt pitch behind text twice its usual size reads as a texture /// rather than as hatching. static func trashHatchSpacing(bodyPointSize: CGFloat) -> CGFloat { em(0.55, bodyPointSize: bodyPointSize) } // MARK: - The board window /// The board window's minimum content size — two standard lanes' worth of width and enough /// height for a header and a few cards. Derived so a large system text size cannot leave the /// window smaller than one lane's own header. static func windowMinimumSize(bodyPointSize: CGFloat) -> CGSize { CGSize( width: em(49, bodyPointSize: bodyPointSize), height: em(31, bodyPointSize: bodyPointSize) ) } // MARK: - The live metric /// The body font's point size as the system currently reports it. /// /// **The app asks the system exactly once, in `CardWindowMetrics.bodyPointSize`**, and this /// forwards to it: the board and the card window must agree about what "the body font" is, or a /// card face and the window it opens into would scale on two different rulers. @MainActor static var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize } }