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
318 lines
16 KiB
Swift
318 lines
16 KiB
Swift
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
|
||
/// 40–48pt) 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.5–3× 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
|
||
}
|
||
}
|