A reserved key comes to life — the card window's sidebar grows a Labels section, and labels stops being somebody else's

`labels` has been a reserved tracker key since the rewrite: preserved verbatim, never
interpreted, drawn only as an anonymous row in the Details section beside `assignees` and
`due`. The owner's cards claim it for first-party use, so it joins the schema — read
leniently (a list of names, a bare scalar coercing to one, a mapping malformed and
preserved), written canonically (a quoted flow list in the order the user arranged, no
auto-sort), and removed outright when the last label goes, the way an expanded lane drops
`collapsed`.

Identity is case-insensitive and display is case-preserving, so a card carries `bug` once
however many ways the board spells it, and entries the reading cannot name ride through the
write untouched at the tail.

The section sits second, above Details — which is the point rather than a layout preference:
Details is where keys the app does *not* own are shown, and this key just stopped being one.
Rows rather than chips, because the sidebar is twenty-six characters wide. The add field
autocompletes against the board's own used-labels universe, derived from every live and
trashed card with no store beside the files, and says out loud when Return would mint a word
the board has never used.

Writes ride a `.relabel` operation of their own, because the commit composer has said
"Relabel card 'X'" since long before there was a control to press — and now the undo row says
it too.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 12:06:01 -04:00
parent fd31ed24f4
commit da37ed61bf
24 changed files with 1928 additions and 29 deletions
+6 -1
View File
@@ -155,7 +155,12 @@ extension FrontmatterDocument {
/// preserved subkey's number or timestamp is emitted by the same code that writes `order` and
/// `created`. Nested collections recurse, which keeps an unknown subkey holding a list from
/// being flattened into its `description`.
private static func flowText(_ value: YAMLValue) -> String {
///
/// **Internal rather than private since 2026-08-09**: `labels` writes a flow *sequence*
/// (`LabelsField.swift`) and carries preserved non-name entries through it, which is the identical
/// question one collection over a second copy of this switch would be a second opinion about how
/// somebody else's YAML is re-emitted.
static func flowText(_ value: YAMLValue) -> String {
switch value {
case .null: "null"
case let .bool(value): FrontmatterValue.bool(value).yamlText
+2
View File
@@ -838,6 +838,7 @@ public enum BoardLoader: Sendable {
icon: document.icon,
iconColor: document.iconColor,
hero: document.hero,
labels: document.labels,
order: order,
attachments: entry.attachments,
commentCount: entry.commentCount,
@@ -1150,6 +1151,7 @@ public enum BoardLoader: Sendable {
icon: document.icon,
iconColor: document.iconColor,
hero: document.hero,
labels: document.labels,
order: order,
attachments: attachments,
commentCount: commentCount,
+19
View File
@@ -271,6 +271,25 @@ public struct Card: Identifiable, Sendable, Equatable {
/// key, and the bytes stay as written.
public let hero: FieldValue<String>
/// **The card's labels** the names the card is tagged with (`FrontmatterKeys.labels`, whose doc
/// comment carries the key's own story; `CardLabels` has the rules; the reading is
/// `FrontmatterDocument.labels`).
///
/// **Card-level only**, `hero`'s posture and for its kind of reason: a label is a property of a
/// piece of work, and neither a lane nor a board is one. The key on a lane or a board stays an
/// ordinary unknown one, preserved verbatim and shown in no sidebar the way it always was.
///
/// The *shape* rather than the answer, so the coerce tier can report a value that had no list
/// reading at all (a mapping); `.missing` and `.valid([])` are both "no labels" and render
/// identically, which is why nothing in the app branches on the difference but the two are
/// distinct bytes on disk and the field keeps them apart.
///
/// **The board face does not draw these** (as of the activation, 2026-08-09). Chips on the card
/// face are their own design question the attachments and comments chips set that vocabulary and
/// a label list is a different shape of thing so this rides in the snapshot for the card window's
/// sidebar, the context menu's submenu, and the board-wide used-labels universe those two share.
public let labels: FieldValue<[String]>
/// Rank within its lane, ascending = top-to-bottom the reading, not necessarily the key. See
/// `Lane.order`'s doc comment; the same reasoning applies here, and a card is where it matters
/// most: the minimum legal agent card is a `mkdir` plus one `index.md` with no `order` at all
+21 -1
View File
@@ -3025,6 +3025,24 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case migrateTombstone(title: String?)
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
/// **A card's `labels` list being rewritten** the sidebar section's chips and the context menu's
/// submenu, the two surfaces the key's 2026-08-09 activation shipped with
/// (`FrontmatterKeys.labels`; `BoardStore.setLabels(_:onCard:on:)`).
///
/// **Its own case rather than a fold into `.style`**, on the vocabulary's standing reasoning. A
/// restyle picks an appearance; this edits what the card *is about*, and 06-history-undo.md's
/// commit-message composer has had a separate word for it since long before there was a control
/// "Relabel card 'X'" (§ Commit messages, named there among the three keys "invisible in the UI
/// though they are"). The word already existed; this is the gesture arriving to claim it. A banner
/// telling someone the app "couldn't style 'Fix login'" after they clicked a label row would name a
/// control they never touched.
///
/// **One case for both directions**, unlike `.collapse`/`.expand`: adding and removing a label are
/// the same control pressed twice a chip's and a menu row's checkmark both just rewrite the
/// list where collapse and expand are two menu rows with two words. `title` is the card's, filled
/// in by `updateIndex` off the document it reads.
case relabel(title: String?)
/// **A generated board background landing** the PNG written into the board folder and the
/// `background` mapping's two subkeys pointed at it, one bracket
/// (`BoardStore.applyGeneratedBackground`; `FacetsGenerator`).
@@ -3359,6 +3377,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .purge: .purge(title: title)
case .migrateTombstone: .migrateTombstone(title: title)
case .style: .style(title: title)
case .relabel: .relabel(title: title)
case .resize: .resize(title: title)
case .collapse: .collapse(title: title)
case .expand: .expand(title: title)
@@ -3410,7 +3429,7 @@ 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, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
.style, .relabel, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
.exportBoard, .importBoard,
.importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .tidyBackgroundImage, .agentGuide,
@@ -3441,6 +3460,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .purge(title): Self.phrase("purge", title)
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
case let .style(title): Self.phrase("style", title)
case let .relabel(title): Self.phrase("relabel", title)
case .setBoardBackground: "set this board's background"
case let .resize(title): Self.phrase("resize", title)
case let .collapse(title): Self.phrase("collapse", title)
+39 -2
View File
@@ -593,6 +593,42 @@ public enum FrontmatterKeys {
/// the reason it always did it is Lanework's to interpret, not an unknown key riding along.
public static let hero = "hero"
/// **A card's labels** (01-storage-format.md § Frontmatter's card table the amendment this key's
/// activation owes; `CardLabels`, `FrontmatterDocument.labels`) a YAML list of names:
/// `labels: ["bug", "ui"]`.
///
/// ### It used to be reserved, and that reversal is the design event
///
/// Through 2026-08-09 this was one of the **reserved tracker keys** beside `assignees`, `due`,
/// `remote` and `remote-state` (01 § Enhanced schema): "the pathfinder shipped them natively; the
/// rewrite reserves them instead", preserved verbatim, never interpreted, and shown only as an
/// anonymous row in the card window's Details section like any other unknown key. The owner's cards
/// ("in card window sidebar add ability to manage card's labels", "add labels submenu to card
/// context menu") claim it for **first-party use**, which retires the reservation for this one name
/// `assignees`, `due` and the two `remote` keys are untouched and stay exactly as reserved as they
/// were.
///
/// **It therefore joins `schemaOwned`**, which is the whole of what stops the Details section
/// drawing a second, raw copy of a field the sidebar now renders properly (`CardDetails.rows`
/// subtracts exactly that set). `hero`'s precedent, and for `hero`'s reason: it is Lanework's to
/// interpret, not an unknown key riding along.
///
/// ### The grammar
///
/// **Read leniently, written canonically** the shape of every lenient field here, one collection
/// up. A sequence reads as its string entries in file order; a bare scalar coerces to a
/// one-element list (`labels: bug`); a mapping has no list reading at all and is `.malformed`.
/// Names are **case-preserving for display and case-insensitively unique within a card**, and
/// entries the reading cannot make a name of are preserved on the way out but never rendered
/// (`CardLabels`).
///
/// **Written as a flow list, in the user's own order** no auto-sort: the order is the order of
/// addition, which is a thing the user arranged and the app has no business restating
/// alphabetically. **Removing the last label removes the key**, the remove-at-default family's rule
/// beside `collapsed`'s expand and the None well's `background`: an absent key is the no-labels
/// reading, so `labels: []` written by the app would be noise every unlabelled card had to carry.
public static let labels = "labels"
public static let created = "created"
public static let modified = "modified"
public static let modifiedBy = "modified-by"
@@ -620,7 +656,8 @@ public enum FrontmatterKeys {
public static let kind = "kind"
/// The reserved tracker keys `remote` (board, card) and `remote-state` (lane) of the enhanced
/// schema's future connectors (01-storage-format.md § Enhanced schema).
/// schema's future connectors (01-storage-format.md § Enhanced schema). **`labels` left this
/// family on 2026-08-09** when the owner claimed it for first-party use; see that key above.
///
/// **Named here without joining `schemaOwned`**, which is the ruling rather than an oversight: 01
/// says the app "treats reserved keys as ordinary unknown keys (preserved verbatim, invisible in
@@ -652,7 +689,7 @@ public enum FrontmatterKeys {
public static let author = "author"
public static let schemaOwned: Set<String> = [
schema, title, order, width, collapsed, hero, created, modified, modifiedBy, deleted,
schema, title, order, width, collapsed, hero, labels, created, modified, modifiedBy, deleted,
background, icon, iconColor, kind,
]
}
+19
View File
@@ -117,6 +117,7 @@ extension FrontmatterDocument {
record(FrontmatterKeys.icon, icon)
record(FrontmatterKeys.iconColor, iconColor)
record(FrontmatterKeys.hero, hero)
record(FrontmatterKeys.labels, labels)
record(FrontmatterKeys.kind, kind)
return found
}
@@ -270,6 +271,24 @@ extension FrontmatterDocument {
}
}
/// **A card's labels** (`FrontmatterKeys.labels`, whose doc comment carries the key's own story
/// including the 2026-08-09 reversal that took it off the reserved-tracker-keys list; the rules
/// themselves are `CardLabels`).
///
/// The lenient family's shape, one collection up. A **sequence** reads as its string entries in
/// file order, case-insensitively deduplicated with the first spelling winning. A **bare scalar**
/// coerces to a one-element list (`labels: bug`) the same "any scalar has a sensible reading"
/// instinct `title` has, narrowed to strings for `CardLabels.name(of:)`'s stated reason. A
/// **mapping**, and a non-string scalar, have no list reading at all: `.malformed`, rendering as no
/// labels, bytes untouched, and a coerce-tier trace left behind.
///
/// A list holding nothing the reader can name `labels: [{a: 1}]` is `.valid([])` rather than
/// malformed: the author wrote a list, which is the right shape, and it happens to contain no
/// names. Those entries survive the next write (`FrontmatterDocument.setLabels`).
public var labels: FieldValue<[String]> {
read(FrontmatterKeys.labels) { value, _ in CardLabels.reading(of: value) }
}
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) } }
+224
View File
@@ -0,0 +1,224 @@
import Foundation
/// **The rules of a card's `labels` list** the pure rulebook behind the read in
/// `FrontmatterFields.swift` and the write below (`FrontmatterKeys.labels` has the key's own story,
/// including why it stopped being a reserved tracker key on 2026-08-09).
///
/// Everything here is a static function over values: no document, no filesystem, no store. The two
/// surfaces that manage labels the card window's sidebar section and the card context menu's
/// submenu both reduce to `toggling`/`adding`/`removing` over a `[String]`, so neither of them can
/// invent a second answer to "does this card already have that label?".
public enum CardLabels {
// MARK: - One name
/// A name as the app stores it: **trimmed of surrounding whitespace, and never empty**.
///
/// `nil` is "there is no name here" an empty string, or a string that is nothing but spaces.
/// That is not a label anybody meant: it would render as a blank chip, match nothing in the
/// used-labels universe, and be unremovable by clicking the thing it does not draw. The empty
/// rename's own rule (`title`: a name that trims to nothing writes no key at all) one collection
/// down.
///
/// **Interior whitespace is left exactly as typed.** `"needs review"` is a perfectly good label
/// and the app has no business folding it to one word or to a hyphen the schema stores names,
/// not identifiers.
public static func normalized(_ text: String) -> String? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
/// The comparison key: a name case-folded, so `Bug` and `bug` are one label.
///
/// **Case-preserving display, case-insensitive uniqueness** (the owner's ruling). The fold is
/// locale-independent on purpose `ItemID.canonicalValue`'s posture, for its reason: this decides
/// identity, and an identity that changed with the user's region would make the same two files
/// mean different things on two machines.
public static func canonical(_ name: String) -> String {
name.lowercased()
}
/// Whether a case-insensitively equal name is already in the list.
public static func contains(_ name: String, in list: [String]) -> Bool {
guard let name = normalized(name) else { return false }
let key = canonical(name)
return list.contains { canonical($0) == key }
}
// MARK: - One list
/// The list with case-insensitive duplicates collapsed, **first spelling wins**, order otherwise
/// preserved.
///
/// The reading applies this, so a hand-written `labels: [Bug, bug]` shows one chip rather than two
/// that cannot be told apart and the next app write to the key lands the collapsed form, which is
/// the on-touch heal every other lenient field already performs by rewriting what it read.
///
/// *First* spelling rather than last, unlike the duplicate-key rule one level up
/// (`FrontmatterDocument.parse`'s last-wins): a key written twice is a file with two answers and the
/// later one is the author's correction, while a list is one value whose members are in an order the
/// author chose so the first occurrence is the one that holds its place.
public static func deduplicated(_ list: [String]) -> [String] {
var seen: Set<String> = []
var result: [String] = []
for entry in list {
guard let name = normalized(entry), seen.insert(canonical(name)).inserted else { continue }
result.append(name)
}
return result
}
/// `name` appended to the list, or the list unchanged when a case-insensitive twin is already
/// there.
///
/// **Appended, never sorted in** the order of addition is the user's arrangement (the key's own
/// no-auto-sort rule), and **the existing spelling wins** a case clash: adding `Bug` to a card that
/// already carries `bug` is a no-op rather than a silent respelling, because the user is adding a
/// label they already have and nothing about that gesture asks to rename it.
public static func adding(_ name: String, to list: [String]) -> [String] {
guard let name = normalized(name), !contains(name, in: list) else { return deduplicated(list) }
return deduplicated(list) + [name]
}
/// The list without any case-insensitive match for `name`.
public static func removing(_ name: String, from list: [String]) -> [String] {
guard let name = normalized(name) else { return deduplicated(list) }
let key = canonical(name)
return deduplicated(list).filter { canonical($0) != key }
}
/// `removing` when the label is there, `adding` when it is not the context menu's row and the
/// dialog's checkbox both press exactly this.
public static func toggling(_ name: String, in list: [String]) -> [String] {
contains(name, in: list) ? removing(name, from: list) : adding(name, to: list)
}
// MARK: - Reading a parsed value
/// One sequence entry's reading as a label, or `nil` when it has none.
///
/// **Only a YAML string is a name**, which is a deliberate narrowing of the scalar-coercion family
/// every other lenient field belongs to (`title: 2048` reads as `"2048"`). Two reasons, and the
/// owner's ruling says the same thing in one sentence ("non-string entries preserved untouched but
/// not rendered"):
///
/// - A single-valued field has one value, so coercing it is the only way to have a reading at all.
/// A list has members, and a member that is not a name can simply be **kept** which is a better
/// outcome than guessing, because nothing is lost either way.
/// - `labels: [2026, ui]` is far more likely to be somebody's structured entry than a card tagged
/// with a number, and the app inventing the string `"2026"` for it would make that entry
/// unremovable-by-intent: the chip would say one thing and the file another.
///
/// **The reading a `setLabels(_:)` will leave behind** `nil` when the write removes the key.
///
/// It exists because an undo step has to declare the after-value its write produced
/// (`ExpectedField.labels`), and for this key that is not simply the list handed in: an empty list
/// removes the key, whose reading is an *absence* rather than an empty list unless entries the
/// reading cannot name are holding the key open, in which case the reading really is `[]`. Two
/// different bytes, two different expectations, and a step that declared the wrong one would skip
/// itself the first time somebody took the last label off a card.
///
/// A pure function rather than a re-read of the written document, so the prediction and the write
/// are the same rule stated once `FrontmatterDocument.setLabels` branches on exactly these two
/// facts.
public static func readingAfterWrite(_ names: [String], preservingEntries: Bool) -> [String]? {
let names = deduplicated(names)
guard !names.isEmpty || preservingEntries else { return nil }
return names
}
/// Also `nil` for a null, a nested sequence, a mapping, and anything that trims to nothing.
static func name(of entry: YAMLValue) -> String? {
guard case let .string(text) = entry else { return nil }
return normalized(text)
}
/// A parsed `labels` value's reading, or `nil` when it has **none at all** the transform behind
/// `FrontmatterDocument.labels`'s `.valid`/`.malformed` split.
///
/// - a **sequence** reads as its string entries, deduplicated, in file order (`[]` for a list with
/// no names in it, which is a perfectly good "no labels" and not a failure);
/// - a **bare scalar** coerces to a one-element list `labels: bug` is a card with one label, the
/// single-value shape an author or an agent reaches for first, and refusing it would make the
/// most forgivable spelling the one shape that renders nothing;
/// - a **mapping** has no list reading and is the one shape that lands `.malformed`, rendering as
/// no labels and leaving the coerce tier's trace.
///
/// A non-string *scalar* at the top level (`labels: 3`) is deliberately **not** coerced, for
/// `name(of:)`'s reason: it reads as no list, so it is malformed and preserved rather than becoming
/// a card labelled "3".
static func reading(of value: YAMLValue) -> [String]? {
switch value {
case let .sequence(entries):
return deduplicated(entries.compactMap(name(of:)))
case let .string(text):
return normalized(text).map { [$0] } ?? []
default:
return nil
}
}
}
// MARK: - The write side
/// **The write side of `labels`** the read side is `FrontmatterDocument.labels`
/// (FrontmatterFields.swift) and the rules are `CardLabels` above. `BackgroundField.swift`'s shape and
/// its reasoning, one collection over.
extension FrontmatterDocument {
/// The entries of the current `labels` value the reading could make no name of what
/// `setLabels(_:)` carries through untouched.
///
/// Empty for every shape that is not a sequence: a bare scalar has one entry and the reading
/// already made a name (or nothing) of it, and a mapping is the malformed shape a forward write
/// **replaces** outright the same malformed-value-cleared posture `icon` has ("choosing any well
/// replaces it", 03-board-ui.md § Styling Controls), and the right one here, since the reader
/// could not make a list of it either.
var preservedLabelEntries: [YAMLValue] {
guard case let .sequence(entries)? = value(for: FrontmatterKeys.labels) else { return [] }
return entries.filter { CardLabels.name(of: $0) == nil }
}
/// Writes the card's labels the **one** mutation every labels surface goes through.
///
/// ### Canonical form
///
/// A single-line **flow sequence** of double-quoted names, in the order given:
/// `labels: ["bug", "needs review"]`. Flow because the span editor rewrites a key's value with one
/// line's worth of text and `FrontmatterValue` has no sequence case to rewrite it with the same
/// `.raw` escape and the same narrow yield of the verbatim promise `setStyleValue` makes for
/// `background`'s mapping, documented at length in `BackgroundField.swift`. Double-quoted for that
/// file's reason exactly: `,` and `]` end a plain scalar in flow context, so a label with a comma
/// in it would otherwise break the collection it is written into.
///
/// **No sort.** The caller's order is the file's order, because the caller's order is the user's:
/// labels accumulate in the sequence they were applied, and an alphabetical rewrite on every touch
/// would be the app rearranging something the user arranged.
///
/// ### The empty case removes the key
///
/// Removing the last label removes `labels` outright rather than writing `labels: []` the
/// remove-at-default family (`collapsed`'s expand, the None well's `background`, a one-unit
/// `width`): an absent key is exactly the no-labels reading, and an empty list on every unlabelled
/// card would be noise the format does not need. The one exception is a list that still holds
/// preserved non-name entries, which keeps the key so those entries survive.
///
/// ### What survives, and what does not
///
/// Entries with no name reading are carried through **at the tail**, after the names. Their
/// *values* survive; their spelling does not a block sequence collapses to flow form, quoting is
/// normalized, and an entry's own inline comment is lost with the line it sat on. Tail rather than
/// in place because a name list has no positions worth preserving *for the names* (the user just
/// rearranged them by definition), and interleaving preserved entries back among them would make
/// the user's order depend on somebody else's structured data.
public mutating func setLabels(_ names: [String]) {
let names = CardLabels.deduplicated(names)
let preserved = preservedLabelEntries
guard !names.isEmpty || !preserved.isEmpty else {
remove(FrontmatterKeys.labels)
return
}
let entries = names.map { FrontmatterValue.emitQuoted($0) } + preserved.map(Self.flowText)
set(FrontmatterKeys.labels, to: .raw("[" + entries.joined(separator: ", ") + "]"))
}
}