Files
lanework/Kanban/History/HistoryPhrase.swift
T
rzen bab456c08d Collapsible lanes — frontmatter-backed slim strips outside the width division
A lane folds to a fixed slim vertical strip carrying its glyph, its card-count
badge and its title turned on its side, and the strip is deliberately not part
of the window's division: the expanded lanes' units divide what is left once
each folded strip's fixed width has come off the top, so folding a lane is a
re-divide trigger of the Show/Hide Trash family — the window never moves and
the siblings grow into what the lane gave up.

The state is a first-class lane frontmatter key, `collapsed: true`, and
document state exactly as `width` is: the files are the board, so an agent
folds a lane by writing one key. Absent means expanded, expanding removes the
key rather than writing `false` (the remove-at-default family beside a
one-unit `width`, the empty rename's `title` and the None well's
`background`), and the lane's `width` rides along untouched so expanding
restores the lane the user had. The read is `width`'s leniency one type over —
a boolean scalar or a quoted boolean word reads as itself, everything else has
no reading at all and renders as expanded, bytes preserved either way.

Toggling is the header's always-visible collapse chevron, the lane context
menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on
the strip; a modified click on the strip stays the ordinary selection grammar,
so a folded lane is still selectable by pointer. The title reads bottom-up and
is justified to the top of the room below the strip's chrome (owner ruling
2026-08-08), truncating against the strip's own height.

While folded the lane draws no cards at all, which is what makes every
exclusion true by construction rather than by a guard per gesture: no card
face means no marquee target and no navigation frame, and no registered grid
means the masonry's drop zones have nothing to resolve against. What did need
code is the half that names absolute destinations — the option-arrow jumps and
the arrow seed scan past a folded lane, the lane domain's down-arrow is inert
on one, and New Card skips it (a selection inside one falls through to the
last-active lane, the stale selection's rule). A drop on the strip appends at
the lane's end, cards and Finder files alike, with an accent edge standing in
for the shadow the strip has no masonry to open; there is no hover-to-auto-
expand yet. Lane reorder works on the strip, and a dragged folded lane carries
its fold, so its shadow and its replica are the strip rather than its units.

The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket,
the same stamp behaviour, the same three do-nothing paths — with two new
`WriteOperation` cases and two new undo verbs rather than one of each, because
a banner or an Edit-menu row that said "resize" after Collapse Lane would name
a control the user never touched.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 22:53:10 -04:00

164 lines
8.5 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"
/// A lane folded to its slim strip (03-board-ui.md § Lane ▸ Collapsed lanes).
///
/// **Two verbs rather than one**, unlike `resize`, which covers growing and shrinking alike:
/// the row has to read back the gesture it undoes, and "Undo Resize Lane" after *Collapse
/// Lane* would name a control the user never touched. The trash pair's Delete/Restore split is
/// the precedent one level up.
case collapse = "Collapse"
case expand = "Expand"
/// 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)"
}
}