Files
lanework/Kanban/History/HistoryPhrase.swift
T
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:43:45 -04:00

156 lines
8.0 KiB
Swift

import Foundation
// MARK: - HistoryPhrase
/// The menu phrase an undo step carries — "Move 3 Cards", "Rename Lane", "Restyle Board".
///
/// ### Why the vocabulary is 06's and not a new one
///
/// 13-native-undo.md ▸ Rules hands the naming straight over: "The 06 vocabulary supplies menu titles
/// ('Undo Move 3 Cards'), via NSUndoManager's dynamic retitling — the same naming machinery both
/// tiers use." So the verbs here are exactly 06-history-undo.md ▸ Commit messages' list — *Add /
/// Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board*, plus
/// the trash pair's **Restore** — and the plural rule is that section's own plural folding ("Delete
/// 12 cards"), which is also 13's coalescing sentence read out loud: "a multi-card move is one step
/// with a plural title".
///
/// The **"Undo "/"Redo " prefix is never here**: the platform composes and localizes it
/// (`BoardUndoManager.undoMenuItemTitle`), and a phrase that spelled it would read "Undo Undo Move
/// Card" in the Edit menu — see `HistoryStep.name`.
///
/// ### Title case, unlike a commit subject
///
/// A commit subject is a sentence ("Move 3 cards to Done"); a menu item is a title, and macOS titles
/// its Edit-menu rows. The words are 06's; the casing is the menu's. Nothing else differs — and the
/// destination clause a commit subject carries has no place in a title that has to stay short enough
/// for a menu row.
///
/// **One deliberate exception, and it is the only one**: `cardSession(_:)` names its card
/// ("Changes to 'Fix login'"), because the coarse close step is the one phrase whose *scope* is what
/// distinguishes it — see that member.
///
/// Pure, and its own type rather than a `String` built at each call site, because a phrase composed
/// in eleven places is a vocabulary that drifts in eleven places.
public enum HistoryPhrase {
// MARK: Verbs
/// 06-history-undo.md ▸ Commit messages' verb list, restricted to the operations 13 makes
/// undoable. `Permanently delete`, `Attach`, `Remove` and `Repair` are deliberately absent —
/// those are exactly the operations that register no step at all (13 ▸ Rules ▸ what is not
/// undoable, ▸ Out of scope).
public enum Verb: String, Sendable, CaseIterable {
/// A create — File ▸ New Lane, the new-card placeholder's commit, a Finder file drop's cards.
case add = "Add"
/// A delete — ⌫, drop-on-trash, the card window's Actions ▸ Delete — which is a *move* into
/// `.trash/` for a card and a physical removal for a lane (03-board-ui.md § Trash).
///
/// **There is no `restore` verb**: restoring is an ordinary move out (drag or ⌘X/⌘V), so it
/// registers as `move` like any other, and Put Back is retired with the tombstone model
/// (resettled 2026-07-28).
case delete = "Delete"
/// A drop that changes an item's parent.
case move = "Move"
/// A drop, a lane drag or ⌥⌘↑/⌥⌘↓ that changes rank among unchanged siblings.
case reorder = "Reorder"
case rename = "Rename"
case restyle = "Restyle"
case resize = "Resize"
/// An Edit session's body save — one step at the Edit→Preview flip (13 ▸ Rules).
case edit = "Edit"
}
// MARK: Nouns
/// What the gesture acted on. `board` is deliberately count-less: there is one board, and
/// "Rename 1 Board" is not a phrase anyone writes.
public enum Kind: Sendable, Equatable {
case card
case lane
case board
/// One comment. `.delete` and `.edit` reach it — the inline edit session joined the
/// vocabulary with the window stack (re-ruled 2026-07-31: "every gesture issued in that
/// window — comment post/delete/**edit**, body Edit sessions ... registers there at fine
/// grain"), registered at its own commit point exactly as the body's session is. Posting has
/// its own phrase (`comment`, below); the draft save and the trash purge still register no
/// step at all (13-native-undo.md — the permanent-delete posture).
case comment
var singular: String {
switch self {
case .card: "Card"
case .lane: "Lane"
case .board: "Board"
case .comment: "Comment"
}
}
var plural: String {
switch self {
case .card: "Cards"
case .lane: "Lanes"
case .board: "Board"
case .comment: "Comments"
}
}
}
// MARK: The comment family
/// **The post's phrase** — 06-history-undo.md's path-shaped "Comment on '⟨card⟩'" as a menu
/// title, which is the verb on its own.
///
/// Not `name(_:kind:count:)` with a `Verb.comment`, because the composition would read "Comment
/// Comment": here the verb already names its object, which is `Kind.board`'s count-less rule
/// arriving from the other direction. The destination clause a commit subject carries ("on 'Fix
/// login'") is dropped exactly as every other phrase drops it — a menu row has to stay short.
public static let comment = "Comment"
// MARK: The card-window session
/// **The coarse close step's phrase** — one card window's whole session, as the board's stack sees
/// it: **"Changes to '⟨card⟩'"** (13-native-undo.md ▸ Rules, ruled 2026-07-31).
///
/// > "the session's net effect registers on the board stack as **one coarse step named
/// > "Changes to '⟨card⟩'"** … the board row reads "Undo Changes to 'Fix login'": plural and
/// > scope-flavoured, distinct from every fine verb, honest about folding many kinds; the fine
/// > body-edit wording never leaks onto the board menu."
///
/// ### Why this one phrase carries its item
///
/// Every other phrase here drops the item clause a commit subject carries, because a verb plus a
/// noun already says what the row is (the type's note). This step has no such verb: what it folds
/// is a comment posted, a colour chosen, a paragraph rewritten, all at once, and a row that named
/// any one of them would be lying about the other two — while enumerating them would be the
/// "Mixed update" problem in a menu (06-history-undo.md ▸ Commit messages). So the *scope* is the
/// phrase, and a scope is only legible when it names what it is the scope **of**.
///
/// ### It is not `Edit Card`, and that is the point
///
/// This member read `name(.edit, kind: .card)` until the ruling, and the fine body-edit step still
/// does (`registerBodyEdit`) — two different steps on two different stacks, one row apart in the
/// Edit menu, saying the same six characters. The board menu now says "Changes to 'Fix login'" and
/// the window menu says "Edit Card": the coarse row names the session, the fine row names the
/// gesture, and neither can be mistaken for the other.
///
/// - Parameter title: the card's title **as the step is registered** — `nil` renders the same
/// placeholder the card's own window title bar renders ("Untitled" is a rendering, never a
/// value — 03-board-ui.md § Card face), so a menu row and the window it came from name the card
/// the same way.
public static func cardSession(_ title: String?) -> String {
"Changes to '\(title ?? "Untitled")'"
}
// MARK: Composition
/// The phrase for one gesture: `"Move Card"`, `"Move 3 Cards"`, `"Restyle Board"`.
///
/// A `count` of one or less folds to the singular — a batch that turned out to name a single item
/// is one item, and a zero never reaches here because a gesture that wrote nothing registers
/// nothing.
public static func name(_ verb: Verb, kind: Kind, count: Int = 1) -> String {
guard count > 1, kind != .board else { return "\(verb.rawValue) \(kind.singular)" }
return "\(verb.rawValue) \(count) \(kind.plural)"
}
}