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)" } }