Files
lanework/Kanban/UI/Board/BoardMetrics.swift
T
rzen c87616f3fb Comments join attachments on the card face — a quiet bubble-and-count chip, present-only
A card whose thread holds one comment or more now draws a second trailing chip beside the
paperclip: a secondary-tinted bubble glyph plus its count, shown only when the count is above
zero (design ruling 2026-08-09, card e729e30a). Same styling family as the attachments chip —
caption size, secondary tint, decorative and hidden outright from the accessibility tree — but
this one carries a visible count rather than staying icon-only, per the ruling's own "bubble-style
SF Symbol + count." It sits after the attachments chip at the row's trailing edge, in both the
live title row and the drag replica.

The count is a new `Card.commentCount` field the loader fills with a readdir over `comments/`'s
identity-shaped children that carry their own `index.md` — `BoardLoader.commentCount(in:)`, built
on the same `identityShapedChildren` predicate a trash entry's held-card count already uses. Never
a parse: `.draft` and `.trash/` are excluded for free, the same dot-prefixed hidden-entry skip
`CommentThread.load` documents for both, so the walk stays exactly the O(cards) shape
01-storage-format.md § Enhanced schema already commits to. Because the count rides inside the
`card: Card` parameter `CardFaceView` already takes — not a new parameter of its own — drawing the
chip costs nothing beyond a field read on an already-compared value: no new Observable read joins
the body, and the equatable gate already covers it via `Card`'s synthesized `Equatable`.

The one divergence from the comments pane's parsed count is documented rather than hidden: a
comment folder whose `index.md` exists but fails to parse is a `Stray` the thread read excludes by
opening and rejecting it, a cost this readdir does not pay. The face may then read one comment
high until that folder is fixed or removed — the trade the ruling's "cheap directory-entry count…
not a parse" asks for, over paying full parse cost on every card of every load. Every well-formed
comment, and every card with no malformed one, agrees with the pane exactly.

VoiceOver: `AccessibilityPhrases.cardValue` gains a `comments: Int` parameter, appended after
attachments and before the cut-pending phrase — the same left-to-right order the two chips draw
in, so a sighted read and a VoiceOver read never disagree about which comes first. The trashed
lane row's own call site (an opaque unit with no comments to speak of) passes `comments: 0`.

Docs: DESIGN/03-board-ui.md's card-face section describes both chips and retires the stale "closed
with no growth" sentence, honestly recording the 2026-08-09 growth (the hero banner landed hours
earlier, this chip after it) as exposure of facts the card already carries rather than a body
excerpt. DESIGN/10-accessibility.md's flattened-element sentence gains the comment count.
DESIGN/01-storage-format.md's Enhanced schema paragraph records the chip as shipped. WISHLIST #9
is marked shipped in place — not renumbered, since #10 and #11 are cross-referenced elsewhere.

Tests: CardCommentCountListingTests (BoardLoaderTests.swift) pins the readdir against a synthetic
tree — no comments/ folder, an empty one, non-identity-shaped and index-less strays excluded,
.draft/.trash/ excluded for free, agreement with CommentThread.load's parsed count in the
well-formed case, and the one documented divergence on a malformed index.md.
AccessibilityPhrasesTests covers cardValue's new parameter alone, alongside attachments, and
all three fragments together. ViewEquatableTests pins that a comment landing on a card is a gate
difference. BoardRenderPerformanceTests adds a render-cost guard: one comment added to one card
on a hosted 180-card board re-renders a handful of bodies, not the board.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 01:21:33 -04:00

318 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
/// **A collapsed lane's slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — the one lane
/// width in the app that is *not* the window's division: a collapsed lane takes this and nothing
/// more, and `LaneLayoutMath.standardWidth` divides what is left among the expanded lanes.
///
/// 3.4 em — 44pt at the standard 13pt body, which is the figure the ruling asked for (roughly
/// 4048pt) and the same multiple `nominalCardHeight` uses, so the strip is about as wide as a
/// default card is tall. Wide enough for the lane's glyph, a rotated title and the count badge
/// stacked in a column; narrow enough that a folded lane reads as a spine rather than as a lane.
///
/// **Font-derived like everything else here**, and it has to be: the strip's whole content is
/// type and a badge, so a figure fixed at 44pt would clip its own glyph at a large system text
/// size, and 10-accessibility.md's full-relative-scaling rule would quietly stop holding. It moves
/// with the board's zoom for the same reason every other figure does.
static func collapsedLaneWidth(bodyPointSize: CGFloat) -> CGFloat {
em(3.4, 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 room the header reserves for the **collapse chevron**, its one piece of trailing chrome
/// (03-board-ui.md § Lane ▸ Collapsed lanes). A new-card button once shared this edge and had its
/// own matching reserve (`newCardButtonReserve`, retired with the button — this milestone); the
/// chevron kept its own figure rather than folding into a since-widowed combined one.
///
/// This is the one figure that is not decoration: the chevron is an `Image` at
/// `.imageScale(.small)`, which *is* a relative size, so a reserve fixed at 18pt would be overrun
/// by the glyph itself at a large system text size and the truncation rule would stop being true.
/// 1.4 em — 18pt at the standard 13pt body — is the glyph plus its breathing room, measured in the
/// same unit it grows in.
static func laneCollapseButtonReserve(bodyPointSize: CGFloat) -> CGFloat {
em(1.4, bodyPointSize: bodyPointSize)
}
/// Everything the lane header's trailing chrome takes — the collapse chevron alone now that the
/// new-card button beside it is gone (this milestone narrowed this from a two-term sum to one).
/// Kept as its own figure rather than inlined at the call site: the one thing the header pads by,
/// so "a long title truncates before it collides" stays one rule however many controls end up
/// living there again.
static func laneHeaderTrailingReserve(bodyPointSize: CGFloat) -> CGFloat {
laneCollapseButtonReserve(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 hero banner's height** (03-board-ui.md § Card face ▸ Hero image) — the band a card
/// draws across the full width of its plate, above the icon-and-title row, when its `hero` key
/// names a readable attachment.
///
/// 2.75 em — 36pt at the standard 13pt body, inside the ruling's "roughly 2.53× the body size".
/// The band is a *sample* of the picture rather than the picture, so the figure is chosen against
/// the row it sits over: a shade under the 44pt a plain one-line card is tall
/// (`nominalCardHeight`), which keeps a hero card recognisably a card — the title still dominates
/// its own face, which is 03's standing rule for everything the face draws.
///
/// **Fixed rather than derived from the image**, so every hero card in a lane bands to the same
/// depth and the masonry stays a masonry; the picture is aspect-fill cropped into it
/// (`CardHeroImage`). Em-scaled like every other figure here, so the band grows with the system
/// text size and with the board's zoom instead of shrinking against a title twice its usual size.
static func cardHeroHeight(bodyPointSize: CGFloat) -> CGFloat {
em(2.75, 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 two trailing chips — attachments, then comments
/// (`CardFaceView.titleRow`; the comments chip joined 2026-08-09).
static func cardRowSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.45, bodyPointSize: bodyPointSize)
}
/// Inside the comments chip only: between its bubble glyph and its count text
/// (`CardFaceView.commentsIndicator`) — tighter than `cardRowSpacing`, which separates the
/// face's own row items, because this is one chip's internal rhythm, closer to a badge's own
/// glyph-to-digit spacing than to a row gap.
static func chipGlyphSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.2, 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
}
}