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
This commit is contained in:
@@ -91,8 +91,12 @@ enum AgentGuide {
|
||||
/// deliberately git-friendly, a board may live in a repository of the user's own, and there an
|
||||
/// agent stages only its own paths, commits its own changes with clear messages, and leaves the
|
||||
/// app-maintained files to the app. The stamping section drops its auto-commit clause the same
|
||||
/// way.
|
||||
static let version = 11
|
||||
/// way. **v12 names the lane's `collapsed` key** (03-board-ui.md § Lane ▸ Collapsed lanes): a lane
|
||||
/// folded to a slim strip is document state exactly like `width`, so an agent can fold and unfold
|
||||
/// lanes by editing frontmatter — and, more to the point, has to know that `collapsed: true` is why
|
||||
/// a lane it wrote a card into is not showing it. One clause beside `width` in Frontmatter, with the
|
||||
/// remove-to-expand rule stated because writing `false` is the mistake the key invites.
|
||||
static let version = 12
|
||||
|
||||
// MARK: - The version marker
|
||||
|
||||
@@ -399,7 +403,11 @@ enum AgentGuide {
|
||||
`iconColor` (color, tints `icon`). Lanes and cards may set `order` (a number;
|
||||
floats are fine) — **optional, and the way to control position**: an item
|
||||
without one goes last. Lanes may set `width` (integer ≥ 1, multiplier of the
|
||||
standard lane width).
|
||||
standard lane width) and `collapsed` (`true` folds the lane to a slim strip
|
||||
in the app; its cards are still there, just not drawn). **To expand a lane,
|
||||
remove the `collapsed` key** rather than writing `collapsed: false` — an
|
||||
absent key is the default, and the app removes it too. A lane's `width`
|
||||
rides along untouched while it is folded.
|
||||
|
||||
**Quote any `title` containing a colon** — `title: Fix: the thing` is
|
||||
invalid YAML; write `title: "Fix: the thing"`. The same goes for any value
|
||||
|
||||
@@ -1052,6 +1052,7 @@ public enum BoardLoader: Sendable {
|
||||
iconColor: document.iconColor,
|
||||
order: order,
|
||||
width: document.width,
|
||||
collapsed: document.collapsed,
|
||||
cards: cards.filter { !withheld.contains(name + "/" + $0.id.rawValue) },
|
||||
document: document
|
||||
)
|
||||
|
||||
@@ -204,6 +204,15 @@ public struct Lane: Identifiable, Sendable, Equatable {
|
||||
/// failing the load (01-storage-format.md § Frontmatter).
|
||||
public let width: FieldValue<Int>
|
||||
|
||||
/// **Folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — `width`'s sibling in
|
||||
/// every respect that matters here: lenient (a value with no boolean reading renders as expanded,
|
||||
/// bytes untouched), document state rather than window state, and preserved verbatim beside it —
|
||||
/// a collapsed lane keeps its `width` so expanding restores the lane the user had.
|
||||
///
|
||||
/// The *reading* is `LaneLayoutMath.isCollapsed(_:)`, which is where the rest of the app asks;
|
||||
/// this is the field, and it carries the shape so the coerce tier can report on it.
|
||||
public let collapsed: FieldValue<Bool>
|
||||
|
||||
/// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
|
||||
/// A card still carrying a legacy `deleted:` key rides along flagged (`Card.isDeleted`) until
|
||||
/// its migration relocates it into `BoardModel.trash` — see `BoardModel.lanes`.
|
||||
|
||||
@@ -2969,6 +2969,24 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// nobody could confuse for another.
|
||||
case setBoardBackground
|
||||
case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)
|
||||
|
||||
/// **A lane folded to its slim strip** — the `collapsed: true` write (03-board-ui.md § Lane ▸
|
||||
/// Collapsed lanes; `BoardStore.setLaneCollapsed`).
|
||||
///
|
||||
/// **Its own case rather than a fold into `.resize`**, on the vocabulary's standing reasoning: the
|
||||
/// two keys sit side by side in a lane's frontmatter and both change how wide the lane draws, but
|
||||
/// the user pressed *Collapse Lane* — and a banner telling them the app "couldn't resize 'Todo'"
|
||||
/// would name a gesture they never made and a control (the stepper, the edge drag) they never
|
||||
/// touched.
|
||||
///
|
||||
/// **And its own case rather than one shared with `.expand`**, which is `.delete`/`.restore`'s
|
||||
/// split one rung down: the two directions are two menu rows with two words, and neither
|
||||
/// direction's failure may be announced in the other's verb.
|
||||
case collapse(title: String?)
|
||||
|
||||
/// **A collapsed lane unfolded** — the write that *removes* `collapsed` (the remove-at-default
|
||||
/// family; `FrontmatterKeys.collapsed`). See `.collapse` for why the pair is two cases.
|
||||
case expand(title: String?)
|
||||
/// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸
|
||||
/// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the
|
||||
/// surfaces" is settled (02-architecture.md § Write-failure surfacing, which names
|
||||
@@ -3223,6 +3241,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .migrateTombstone: .migrateTombstone(title: title)
|
||||
case .style: .style(title: title)
|
||||
case .resize: .resize(title: title)
|
||||
case .collapse: .collapse(title: title)
|
||||
case .expand: .expand(title: title)
|
||||
case .rename: .rename(title: title)
|
||||
case .duplicateBoard: .duplicateBoard(title: title)
|
||||
case .saveAsTemplate: .saveAsTemplate(title: title)
|
||||
@@ -3269,7 +3289,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// reading to be about — and `.deleteComment`'s move into `comments/.trash/` stamps for the
|
||||
// plain container reason its board-level twin does.
|
||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
|
||||
.style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .paste,
|
||||
.importAttachment,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.mintBoardIndex, .stampSchema, .setBoardBackground,
|
||||
.displaceClaimedName,
|
||||
@@ -3300,6 +3321,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .style(title): Self.phrase("style", title)
|
||||
case .setBoardBackground: "set this board's background"
|
||||
case let .resize(title): Self.phrase("resize", title)
|
||||
case let .collapse(title): Self.phrase("collapse", title)
|
||||
case let .expand(title): Self.phrase("expand", title)
|
||||
case let .rename(title): Self.phrase("rename", title)
|
||||
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
|
||||
case let .saveAsTemplate(title): Self.phrase("save as template", title)
|
||||
|
||||
@@ -560,6 +560,20 @@ public enum FrontmatterKeys {
|
||||
public static let title = "title"
|
||||
public static let order = "order"
|
||||
public static let width = "width"
|
||||
|
||||
/// **A lane folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes; 01-storage-format.md
|
||||
/// § Frontmatter's lane table) — `collapsed: true`, and nothing else.
|
||||
///
|
||||
/// Document state exactly like `width`, deliberately rather than window state: the files *are* the
|
||||
/// board, so an agent folds a lane away by writing one key and the app follows on the next reload.
|
||||
///
|
||||
/// **Only ever written as `true`, and removed to expand** (the remove-at-default family beside the
|
||||
/// empty rename's `title`, the None well's `background`, and a one-unit `width`): an absent key is
|
||||
/// the default reading, so a `collapsed: false` the app wrote would be noise a default lane has to
|
||||
/// carry. A hand-written `false` is legal, reads as expanded, and is preserved until the app itself
|
||||
/// next edits the key.
|
||||
public static let collapsed = "collapsed"
|
||||
|
||||
public static let created = "created"
|
||||
public static let modified = "modified"
|
||||
public static let modifiedBy = "modified-by"
|
||||
@@ -619,7 +633,7 @@ public enum FrontmatterKeys {
|
||||
public static let author = "author"
|
||||
|
||||
public static let schemaOwned: Set<String> = [
|
||||
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon,
|
||||
iconColor, kind,
|
||||
schema, title, order, width, collapsed, created, modified, modifiedBy, deleted, background,
|
||||
icon, iconColor, kind,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ extension FrontmatterDocument {
|
||||
}
|
||||
record(FrontmatterKeys.title, title)
|
||||
record(FrontmatterKeys.width, width)
|
||||
record(FrontmatterKeys.collapsed, collapsed)
|
||||
record(FrontmatterKeys.created, created)
|
||||
record(FrontmatterKeys.modified, modified)
|
||||
record(FrontmatterKeys.modifiedBy, modifiedBy)
|
||||
@@ -215,6 +216,31 @@ extension FrontmatterDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Whether the lane is folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) —
|
||||
/// `width`'s reading one type over, and lenient in exactly its way.
|
||||
///
|
||||
/// A `true`/`false` scalar reads as itself, and so does any of YAML 1.1's other boolean spellings
|
||||
/// (`yes`/`no`, `on`/`off`, `y`/`n`) written **quoted**: unquoted, the parser has already resolved
|
||||
/// them to `.bool` for us, and a quoted one is the same word the author meant — the numeric string
|
||||
/// `width: "2"` coercing to `2` is the precedent, and it is the one thing lenient reading means.
|
||||
/// Everything else — a number, a sequence, a mapping, `collapsed: maybe` — has no boolean reading
|
||||
/// at all and is malformed, **which renders as expanded**: the default is the absent key's, so an
|
||||
/// unreadable value can only ever fold a lane by accident, and it never does.
|
||||
///
|
||||
/// Read-side only, like every other coercion here: the bytes stay exactly as the author wrote them
|
||||
/// until the user (or an agent) changes the key, and `collapsed: false` is a perfectly good way to
|
||||
/// write "expanded" by hand even though the app removes the key instead
|
||||
/// (`FrontmatterKeys.collapsed`).
|
||||
public var collapsed: FieldValue<Bool> {
|
||||
read(FrontmatterKeys.collapsed) { value, _ in
|
||||
switch value {
|
||||
case let .bool(value): value
|
||||
case let .string(text): Self.boolWord(text)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
|
||||
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
|
||||
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } }
|
||||
@@ -300,6 +326,17 @@ extension FrontmatterDocument {
|
||||
return value >= 1 ? Int(value) : 1
|
||||
}
|
||||
|
||||
/// YAML 1.1's boolean words, for a **quoted** scalar that spells one — the coercion `collapsed`
|
||||
/// needs and no other field wants. Case-insensitive, because the grammar itself is (`True`, `ON`,
|
||||
/// `Yes` are all booleans unquoted); anything else has no boolean reading.
|
||||
private static func boolWord(_ text: String) -> Bool? {
|
||||
switch text.lowercased() {
|
||||
case "true", "yes", "on", "y": true
|
||||
case "false", "no", "off", "n": false
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// A quoted timestamp reads the same as an unquoted one — same YAML 1.1 timestamp grammar,
|
||||
/// a superset of ISO-8601. Anything that isn't a valid timestamp either way has no sensible
|
||||
/// date reading and is malformed; per the tombstone rule (`Lane`/`Card.isDeleted`), a
|
||||
|
||||
Reference in New Issue
Block a user