Files
lanework/Kanban/UI/AccessibilityPhrases.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

456 lines
25 KiB
Swift

import Foundation
// MARK: - AccessibilityPhrases
/// **What the board's elements say** — every label and value VoiceOver reads off the board window,
/// composed by pure functions (10-accessibility.md ▸ The board through VoiceOver).
///
/// ### Why the strings live here and not at the modifiers
///
/// 10-accessibility.md states the board's tree as *sentences*: a lane container is
/// "⟨title⟩, lane, N cards", its collapse chevron is "Collapse ⟨lane⟩", a card's value carries its
/// attachment count and, when it is cut-pending, "cut, pending paste". Those are rules about text —
/// which placeholder an untitled item wears, how a count folds its plural, what order two value
/// fragments join in — and a rule about text is only checkable if there is a function to ask. Split
/// out, the whole spoken vocabulary is pinned by `AccessibilityPhrasesTests` without a window, a
/// screen reader, or a running app; `Motion`, `TrashModel.purgePrompt` and `HistoryPhrase` are the
/// same shape for the same reason.
///
/// It is also the one place the board and the trash column can be made to *agree*: the lane's spoken
/// count and the trash's are one function, the untitled placeholder is one constant, and the
/// attachment phrase the card face used to spell inline is now the same string the flattened card
/// element carries in its value.
enum AccessibilityPhrases {
// MARK: - Shared vocabulary
/// The untitled placeholder — **the same word the face draws** (`LaneView.headerTitle`,
/// `CardFaceView.titleOrEditor`), because 10-accessibility.md asks for "label = title (or the
/// untitled placeholder)" and a spoken placeholder that differed from the visible one would make
/// a sighted user and a VoiceOver user describe different boards.
static let untitled = "Untitled"
/// An item's spoken name: its title, or the untitled placeholder. Total, so no caller branches.
static func displayTitle(_ title: String?) -> String {
guard let title, !title.isEmpty else { return untitled }
return title
}
/// What the pre-snapshot loading surface says (02-architecture.md § Launch and window lifecycle;
/// `BoardLoadingView`). The surface draws a bare system spinner and no text — "no skeleton lanes,
/// the motion language animates real data only" — so this is the *only* description a VoiceOver
/// user gets of a board window that is still walking its tree, and a spinner with nothing to say
/// would leave that window silent.
static let boardLoading = "Loading board"
/// **What the decision surface is called** (01-storage-format.md § Malformed input, settled
/// 2026-07-31; `BoardDecisionSurface`) — its heading *and* the group label a VoiceOver user hears
/// on entering it, which are one string here for the banner row's reason exactly: the sentence
/// heard and the sentence read are one, or they are two descriptions of one thing waiting to
/// disagree.
///
/// It says the board did not open and stops there. What is wrong is the sections' to say — the
/// surface exists precisely because there is usually more than one answer to that.
static let decisionSurfaceLabel = "Lanework couldn't open this board"
/// The surface's own subtitle: how much is wrong, and what to do about it. The count is the
/// number of affected *files*, which is what the rows below it are.
static func decisionSurfaceSummary(defects count: Int) -> String {
let subject = count == 1 ? "One file needs" : "\(count) files need"
return "\(subject) a decision before this board can open."
}
/// One affected file, as one utterance: **path then reason**, in that order, because the path is
/// what identifies the row and the reason is what the user is deciding about
/// (01: "lists the affected files (path + specifics …)").
///
/// The reason is the loader's own sentence, unrewritten — the same specifics the row shows — for
/// `BannerCenter.causePhrase`'s reason: fail-fast's diagnostics are specific in a way a
/// re-phrasing would not be, and the alternative to showing one is a shrug.
static func decisionRowLabel(path: String, reason: String) -> String {
"\(path): \(reason)"
}
/// "3 cards", "1 card" — the app's **one** plural folding for a card count, borrowed from
/// `TrashModel.phrase` rather than restated so a lane's spoken count and the trash column's
/// cannot drift apart.
static func cardCount(_ count: Int) -> String {
TrashModel.phrase(count)
}
// MARK: - Lanes
/// A lane container's label — "⟨title⟩, lane, N cards" (10-accessibility.md ▸ The board through
/// VoiceOver).
///
/// **The count is the caller's, and the caller passes the rendered one**: "the count reads the
/// search filter like the visible badge", so `LaneView` hands the very collection its badge
/// counts (`renderedCards`) and the two can no more disagree than the badge can disagree with
/// the masonry.
/// **A folded lane says so** (03-board-ui.md § Lane ▸ Collapsed lanes; 10-accessibility.md's
/// never-colour-alone family, read one rung out: a state a sighted user reads off the strip's
/// shape has to be a word for everyone else). It sits between the level and the count, which is
/// where the container's own qualifier belongs — "Doing, lane, collapsed, 5 cards" — and the count
/// stays the lane's *held* cards, since a collapsed lane renders none and "0 cards" would say the
/// lane was empty when it is merely shut.
///
/// Defaulted, so every caller that has no fold to report reads exactly as it did before.
static func laneLabel(title: String?, cards count: Int, collapsed: Bool = false) -> String {
let state = collapsed ? "collapsed, " : ""
return "\(displayTitle(title)), lane, \(state)\(cardCount(count))"
}
/// The lane header's **collapse chevron** — "Collapse ⟨lane⟩" (03-board-ui.md § Lane ▸ Collapsed
/// lanes), the header's one labeled child: a glyph-only control needs a word, and the word names
/// the lane so the label stands on its own out of context.
static func collapseLaneLabel(lane title: String?) -> String {
"Collapse \(displayTitle(title))"
}
// MARK: - Cards
/// A card's label: its title, or the untitled placeholder. Named rather than inlined so the
/// card element and the lane's own title read through one function.
static func cardLabel(title: String?) -> String {
displayTitle(title)
}
/// "1 attachment", "4 attachments" — the paperclip chip's information, moved into the card
/// element's value where 10-accessibility.md puts it ("the flattened element carries the
/// attachment count in its value"). Plural-folded like every other count in the app; the chip
/// itself used to say "N attachments" unconditionally, which read wrong at one.
static func attachmentCount(_ count: Int) -> String {
"\(count) attachment\(count == 1 ? "" : "s")"
}
/// The deferred cut's spoken half — "cut items dim in place until paste moves them"
/// (04-interactions.md ▸ Clipboard), and **state is never colour-alone** (10-accessibility.md):
/// the dim is the sighted signal, this is the other one.
static let cutPending = "cut, pending paste"
/// A card element's value — the attachment count and the comment count when the card has either
/// (design ruling 2026-08-09, card e729e30a — the comments chip's face value gains "N comments"
/// beside the existing attachment wording), the cut-pending phrase when it is staged for paste,
/// any mix of the three, and **the empty string when none apply**.
///
/// **Attachments before comments**, matching the chips' own left-to-right order on the face
/// (`CardFaceView.titleRow`: attachments, then comments) — one reading order for the two
/// surfaces, so a sighted user's eye and a VoiceOver user's ear never disagree about which comes
/// first. `commentCount` is `AccessibilityPhrases`' own — the pane's plural folding, reused
/// rather than restated, so a face value and the comments pane's header can never fold "1
/// comment" two different ways.
///
/// Empty rather than `nil` on purpose: the modifier that consumes it is unconditional, because a
/// `if` around `.accessibilityValue` would put the whole card face inside a `_ConditionalContent`
/// that flips identity — and therefore rebuilds the face, dropping its measured height and its
/// marquee registration — the moment an attachment lands, a comment posts, or a cut is pasted. An
/// empty AXValue speaks as nothing, which is exactly what "no value" should sound like.
static func cardValue(attachments: Int, comments: Int, isCutPending: Bool) -> String {
var parts: [String] = []
if attachments > 0 { parts.append(attachmentCount(attachments)) }
if comments > 0 { parts.append(commentCount(comments)) }
if isCutPending { parts.append(cutPending) }
return parts.joined(separator: ", ")
}
// MARK: - The trash column
/// The trash container's label — stable, like the header's visible title (03-board-ui.md §
/// Trash: one word, never "Hide Trash"-style state in the name).
static let trashLabel = "Trash"
/// The trash container's value: its counts, filtered exactly as the lane labels' are — "the
/// shown trash's cards participate in the filter exactly like any other card" (03-board-ui.md §
/// Trash), so the column passes the collections its badge counts.
///
/// **Both kinds are named when both are there** (lanes rejoined the trash 2026-07-29): the
/// container's rows are cards *and* opaque lane units, and a value that spoke only the cards
/// would understate what the VoiceOver cursor is about to walk into. A trash holding no lane rows
/// — overwhelmingly the common case — reads exactly as it always did.
///
/// It stops at counting, deliberately: the *freight* phrasing ("2 lanes containing 9 more
/// cards") belongs to the purge confirmations, where the number is what the user is about to
/// lose (`TrashModel.subject(for:)`).
static func trashValue(cards: Int, lanes: Int = 0) -> String {
guard lanes > 0 else { return cardCount(cards) }
return "\(cardCount(cards)), \(laneCount(lanes))"
}
/// A trashed lane row — **one flattened opaque element**, "⟨title⟩, deleted lane, N cards"
/// (10-accessibility.md ▸ Trash lane, lanes rejoined 2026-07-29).
///
/// "Deleted lane" and not "lane" is the whole of what the label has to carry beyond a live lane
/// container's: the row is in the trash, it is not a container, and its N is the freight it took
/// with it rather than a filtered count of children VoiceOver could enter (`TrashedLane`).
static func trashedLaneLabel(title: String?, cards count: Int) -> String {
"\(displayTitle(title)), deleted lane, \(cardCount(count))"
}
/// What View ▸ Show Trash announces — "toggling visibility is announced"
/// (10-accessibility.md ▸ Trash lane). A whole container joining or leaving the board is a
/// layout change with no focus consequence and therefore nothing else to notice it by.
///
/// Phrased as the resulting *state* rather than as the action ("Trash shown", not "Showing
/// trash"), because the toolbar item and the menu checkmark both mean the same thing and a user
/// who mis-hit the toggle needs to know where the board ended up.
static func trashVisibility(shown: Bool) -> String {
shown ? "Trash shown" : "Trash hidden"
}
/// What View ▸ Zoom In / Zoom Out / Actual Size announces — "a zoom change announces its new
/// level" (10-accessibility.md ▸ Text scaling).
///
/// `trashVisibility`'s rule exactly, and for its reason: the resulting *state* rather than the
/// action, because three commands and two toolbar buttons all land on one ladder and what a user
/// needs to hear is which rung they are on — not that something moved. It is also the only signal
/// there is: nothing gains or loses focus, no element's label or value changes, and the whole
/// effect is a redraw a VoiceOver user cannot see.
static func zoomLevel(_ percent: String) -> String {
"Zoom \(percent)"
}
// MARK: - Live board announcements
/// "3 lanes", "1 lane" — `cardCount`'s twin, and the second half of the digest's plural folding.
/// Borrowed from `TrashModel.lanePhrase` for `cardCount`'s reason: since lanes rejoined the trash
/// (2026-07-29) the purge confirmations count lanes too, and one folding means a spoken count and
/// a confirmed one cannot drift.
static func laneCount(_ count: Int) -> String {
TrashModel.lanePhrase(count)
}
/// The digest's opening — and, on its own, the whole sentence for a change no bucket counts.
///
/// Named for the sentence rather than overloading `boardChanged(_:)`: a constant and a function
/// sharing a base name would make `"\(boardChanged)"` an ambiguity a reader has to resolve by
/// hand, and string interpolation accepts either.
static let boardChangedSubject = "Board changed"
/// **One polite digest per reload debounce** — 10-accessibility.md ▸ Live board announcements,
/// whose own example sentence this reproduces: "Board changed: 2 cards edited, 1 card added".
///
/// Three phrasing rules, all pinned by `AccessibilityPhrasesTests`:
///
/// - **Zero categories are omitted**, never spoken as "0 cards moved". A digest is a summary,
/// and a summary that lists what did *not* happen is a list of noise with the news buried in
/// it.
/// - **Counts fold their plural** (`cardCount`, `laneCount`), like every other count in the app.
/// - **The order is fixed**: cards before lanes, and within each kind edited, added, moved,
/// deleted. Cards lead because they are what a board is mostly made of and what 10's example
/// leads with; within a kind the order runs from the change that leaves the board's shape
/// alone to the one that takes something out of it, so the sentence ends on the fragment most
/// likely to need acting on.
///
/// `nil` — silence — when nothing differs at all, which is the ordinary outcome of a reload that
/// re-read an unchanged tree. A change that no bucket counts (a renamed board, an edited board
/// description) still says *something*: "Board changed", bare. Announcing nothing there would be
/// the lie 10's principle names — "silence about a mutating board is a lie to a VoiceOver user".
static func boardChanged(_ diff: BoardDiff) -> String? {
var fragments: [String] = []
appendFragments(of: diff.cards, counting: cardCount, to: &fragments)
appendFragments(of: diff.lanes, counting: laneCount, to: &fragments)
guard !fragments.isEmpty else {
return diff.boardChanged ? boardChangedSubject : nil
}
return "\(boardChangedSubject): \(fragments.joined(separator: ", "))"
}
/// One kind's fragments, in the fixed category order. `counting` is the kind's plural folding,
/// passed in so the two kinds are one piece of code rather than two that could drift.
private static func appendFragments(
of changes: BoardDiff.Changes,
counting count: (Int) -> String,
to fragments: inout [String]
) {
if !changes.edited.isEmpty { fragments.append("\(count(changes.edited.count)) edited") }
if !changes.added.isEmpty { fragments.append("\(count(changes.added.count)) added") }
if !changes.moved.isEmpty { fragments.append("\(count(changes.moved.count)) moved") }
if !changes.deleted.isEmpty { fragments.append("\(count(changes.deleted.count)) deleted") }
}
/// **A vanishing focus is called out specifically** (10 ▸ Live board announcements) — the
/// design's own two sentences, "Card 'Fix login' was deleted externally" and "Lane 'Doing' was
/// deleted externally, with 5 cards".
///
/// The lane form's trailing clause is what makes the substitution honest: when the lane went, it
/// took cards with it, and naming the lane *without* the count would hide the larger half of
/// what happened. It is omitted at zero — an empty lane vanishing has no second clause to add,
/// and "with 0 cards" would be an odd way to say "and nothing else".
///
/// "Externally" and not "by an agent" or "on disk": the sentence is only ever composed for a
/// vanishing the `EchoLedger` did not vouch for (`BoardStore.vanishingIsForeign`), and which
/// outside writer did it — an editor, an agent, `git` in a terminal — is exactly what the store
/// cannot know.
static func vanishedFocus(_ vanished: BoardAnnouncer.VanishedFocus) -> String {
switch vanished {
case let .card(title):
"Card '\(displayTitle(title))' was deleted externally"
case let .lane(title, cards):
cards == 0
? "Lane '\(displayTitle(title))' was deleted externally"
: "Lane '\(displayTitle(title))' was deleted externally, with \(cardCount(cards))"
}
}
// MARK: - The comments pane
/// The pane's container label — **"Comments, N"** (10-accessibility.md ▸ Comments: "the pane is a
/// labeled container ('Comments, N')").
///
/// The count is the *thread's*, not the rendered rows' — there is no filter over a thread — and it
/// is the same number the visible header shows (`CommentsHeader.title(count:)`), which is the same
/// discipline the lane label keeps with its badge.
static func commentsContainerLabel(count: Int) -> String {
"Comments, \(count)"
}
/// "3 comments", "1 comment" — the pane's plural folding, beside `cardCount` and `laneCount`.
static func commentCount(_ count: Int) -> String {
"\(count) comment\(count == 1 ? "" : "s")"
}
/// **One comment as a single flattened element's label** (10 ▸ Comments: "each comment is **one
/// flattened element** — author, date, edited state, body").
///
/// The author line is the label and the body is the value (`commentValue`), which is the same split
/// the card element makes: the label is what the element *is*, the value is what it currently
/// holds. A comment with no author line at all — no name, no date — falls back to the word
/// "Comment", because an unlabeled element is an audit failure and "unattributed" is the absence of
/// a name rather than a name to speak (`CommentAuthorLine`).
static func commentLabel(authorLine: String?) -> String {
guard let authorLine, !authorLine.isEmpty else { return commentSubject }
return authorLine
}
/// What a comment with nothing to attribute is called. Named rather than inlined because both the
/// label fallback and the empty pane's hint read it.
static let commentSubject = "Comment"
/// A comment element's **value**: its body, with the attachment count appended when it has files.
///
/// The body is spoken as it is rather than summarized: 10 puts "body" in the flattened element, and
/// a comment is short by nature — the thing a thread is *for* is the text, and paraphrasing it
/// would be the app deciding what a VoiceOver user may hear of somebody's comment. Empty for a
/// comment with neither, which speaks as nothing (`cardValue`'s rule).
static func commentValue(body: String, attachments: Int) -> String {
var parts: [String] = []
let text = body.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty { parts.append(text) }
if attachments > 0 { parts.append(attachmentCount(attachments)) }
return parts.joined(separator: ", ")
}
/// The three custom actions a comment element carries — its context menu's rows, which is what 10
/// asks for ("with its context-menu rows (Edit / Delete / Reveal in Finder) riding as custom
/// actions per the cut").
///
/// Constants rather than literals at the modifier because the menu row and the custom action must
/// be the *same* string: a user who has learned the pointer inventory should hear the same three
/// words from the rotor.
static let commentEditAction = "Edit"
static let commentDeleteAction = "Delete"
static let commentRevealAction = "Reveal in Finder"
/// The composer, labeled — "the composer is a labeled text field (⌘↩ posts)" (10 ▸ Comments).
static let commentComposerLabel = "Add a comment"
/// The sort control's label. Its *value* is the direction's own word
/// (`CommentSortDirection.controlLabel`), which is also its help text — one string, three readers.
static let commentSortLabel = "Sort"
/// The paperclip on either authoring surface.
static let commentAttachFilesLabel = "Attach Files"
// MARK: - Foreign comment changes
/// **What a foreign comment change says out loud** — path-shaped, naming the card
/// (10-accessibility.md ▸ Comments: "Foreign comment arrivals announce path-shaped ('New comment on
/// '⟨card⟩'') — the window-scoped read never blocks the announcement, which composes from the path
/// alone").
///
/// ### Precedence, not concatenation
///
/// One polite sentence per reload is the whole ladder's rule (`BoardAnnouncer`), so three
/// simultaneous kinds of change pick one: **arrivals lead** — they are the news 10 names and the
/// only kind that adds something to read — then edits, then deletions. A thread that gained one
/// comment and lost another says the gain; the loss is visible in the pane and has no reader
/// waiting on it.
///
/// ### The verbs are 06's family, and the plurals are built on them
///
/// 10 names one sentence and 01 names the family the other two come from ("a changed path under
/// `…/comments/<uuid>/` composes 'Comment on ⟨card title⟩' / 'Edit comment on…' / 'Delete comment
/// on…'"). The singular forms are those verbs verbatim; the plural forms fold a count in front,
/// like every other count in the app, rather than repeating the sentence N times.
static func commentsChanged(_ changes: CommentThreadChanges, onCard title: String?) -> String? {
let card = displayTitle(title)
if let phrase = commentFragment(
count: changes.arrived.count,
singular: "New comment",
plural: { "\($0) new comments" },
onCard: card
) {
return phrase
}
if let phrase = commentFragment(
count: changes.edited.count,
singular: "Edit comment",
plural: { "\($0) comments edited" },
onCard: card
) {
return phrase
}
return commentFragment(
count: changes.deleted.count,
singular: "Delete comment",
plural: { "\($0) comments deleted" },
onCard: card
)
}
/// One bucket's sentence, or `nil` at zero — the shape all three share, written once so the three
/// cannot drift in punctuation or in where the card's name sits.
private static func commentFragment(
count: Int,
singular: String,
plural: (Int) -> String,
onCard card: String
) -> String? {
guard count > 0 else { return nil }
return "\(count == 1 ? singular : plural(count)) on '\(card)'"
}
// MARK: - The banner strip
/// What VoiceOver says before a banner's headline. "Status" rather than "Info" because that is
/// the word the platform uses for a non-alarming state announcement.
///
/// Moved here from `BannerStripView` when the strip's rows started being *announced* as well as
/// read: 10 makes the live-reload-resilience banner "an accessibility element … announced when
/// it appears and when it clears", and the row's label and its announcement must be the same
/// sentence or a user would hear the condition described two different ways.
static func bannerTonePrefix(_ tone: BannerTone) -> String {
switch tone {
case .error: "Error"
case .warning: "Warning"
case .info: "Status"
}
}
/// A banner row as one spoken element — tone first, because a VoiceOver user must hear *that*
/// this is an error before hearing what the error is, and colour cannot carry that.
static func bannerLabel(tone: BannerTone, headline: String) -> String {
"\(bannerTonePrefix(tone)): \(headline)"
}
/// The read-only lock clearing. Stated as the regained capability rather than as the cause's
/// disappearance ("the volume came back") because the causes are three and the consequence is
/// one, and the consequence is what the user was waiting on.
static let readOnlyLockCleared = "The board is editable again"
/// Reload breakage clearing — the board is reading its files again, which is a smaller claim
/// than the lock's and is deliberately phrased as one.
static let reloadBreakageCleared = "The board is loading again"
}