View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
445 lines
24 KiB
Swift
445 lines
24 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", the header button is "New card in ⟨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.
|
|
static func laneLabel(title: String?, cards count: Int) -> String {
|
|
"\(displayTitle(title)), lane, \(cardCount(count))"
|
|
}
|
|
|
|
/// The lane header's new-card button — "New card in ⟨lane⟩", the one labeled child
|
|
/// 10-accessibility.md gives the header.
|
|
static func newCardLabel(lane title: String?) -> String {
|
|
"New card in \(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 when it has files, the cut-pending phrase when
|
|
/// it is staged for paste, both when both, and **the empty string when neither**.
|
|
///
|
|
/// 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 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, isCutPending: Bool) -> String {
|
|
var parts: [String] = []
|
|
if attachments > 0 { parts.append(attachmentCount(attachments)) }
|
|
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"
|
|
|
|
/// The unreadable repository clearing (06-history-undo.md ▸ Rules, ruled 2026-07-31: "the banner
|
|
/// clears when a later open or reload finds the repo readable").
|
|
///
|
|
/// Stated as the regained capability, `readOnlyLockCleared`'s rule: what the user was waiting on
|
|
/// is history advancing again, not libgit2 changing its mind about a folder. It stays the
|
|
/// smaller claim of the two — nothing about editing the board was ever blocked by this
|
|
/// condition, which is precisely what its own row says.
|
|
static let repositoryUnreadableCleared = "History is recording again"
|
|
}
|