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:
@@ -0,0 +1,294 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The seam
|
||||
|
||||
/// **What the Labels section shows**, as pure functions over values — the section's rules, stated
|
||||
/// where a test can hold them rather than as `if`s inside a view body (`CardDetails`' own posture).
|
||||
enum CardLabelsPicker {
|
||||
|
||||
/// How many suggestions the add field offers at once. Small on purpose: the sidebar is 26
|
||||
/// characters wide, the list pushes the sections below it down while it is open, and a field whose
|
||||
/// suggestion list is longer than the section it sits in has stopped being a hint.
|
||||
static let suggestionLimit = 6
|
||||
|
||||
/// The labels the add field offers for `query`, in `universe`'s own order (frequency first —
|
||||
/// `LabelIndex`), never including one the card already carries.
|
||||
///
|
||||
/// **An empty query offers the board's top labels rather than nothing.** Opening the field on a
|
||||
/// board that already has a vocabulary should show it — that is the difference between an
|
||||
/// autocomplete and a blank box, and it is the sidebar's answer to the same question the context
|
||||
/// menu answers with its twelve rows.
|
||||
///
|
||||
/// Matching is a **case- and diacritic-insensitive substring**, which is board search's own rule
|
||||
/// (04-interactions.md § Search) and therefore the one match semantics this app has.
|
||||
static func suggestions(
|
||||
for query: String,
|
||||
universe: [String],
|
||||
existing: [String],
|
||||
limit: Int = suggestionLimit
|
||||
) -> [String] {
|
||||
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let candidates = universe.filter { !CardLabels.contains($0, in: existing) }
|
||||
guard !query.isEmpty else { return Array(candidates.prefix(limit)) }
|
||||
let matches = candidates.filter {
|
||||
$0.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
|
||||
}
|
||||
return Array(matches.prefix(limit))
|
||||
}
|
||||
|
||||
/// Whether committing `query` would **create** a label the board has never used — what the field's
|
||||
/// footer says out loud, so nobody mints a near-duplicate by accident.
|
||||
///
|
||||
/// `false` for a name that trims to nothing (there is nothing to create) and for one the board
|
||||
/// already spells some way (that is an *apply*, and the board's spelling is what lands —
|
||||
/// `LabelCommand.resolved`).
|
||||
static func createsNewLabel(_ query: String, universe: [String]) -> Bool {
|
||||
guard let name = CardLabels.normalized(query) else { return false }
|
||||
return !CardLabels.contains(name, in: universe)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The section
|
||||
|
||||
/// The sidebar's **Labels** section: the card's labels as removable rows, plus a quiet add field with
|
||||
/// autocomplete against the board's used-labels universe and free-text creation
|
||||
/// (`FrontmatterKeys.labels`, activated 2026-08-09 — the key's own doc comment has the reversal's
|
||||
/// story; 05-card-window.md ▸ The attributes sidebar owes an amendment naming this section).
|
||||
///
|
||||
/// ### Where it sits, and why
|
||||
///
|
||||
/// **Second: after Style, before Details, above Attachments.** The stack reads Style · Labels ·
|
||||
/// Details · Attachments, and each boundary is a decision:
|
||||
///
|
||||
/// - **After Style** rather than before it, because Style is the section every card has and the one a
|
||||
/// user learns the sidebar by. Labels are the first *content* attribute, and content comes after
|
||||
/// appearance in a stack whose top is the card's look.
|
||||
/// - **Before Details**, which is the load-bearing one. Details is the section for keys the app does
|
||||
/// **not** own ("the raw source outlet is the write path for frontmatter the app doesn't own"), and
|
||||
/// `labels` just stopped being one of those. Putting a first-party, editable section below the
|
||||
/// read-only overflow bin would read as an afterthought appended to the unknowns; putting it above
|
||||
/// says what is true — the app owns this key now, and Details is what is left over.
|
||||
/// - **Above Attachments**, which is settled and not mine to move: Attachments stays at the bottom of
|
||||
/// the stack (reordered there 2026-08-09, Pipeline card 8f26b029) because it is the section that
|
||||
/// grows without bound and advertises a drop surface.
|
||||
///
|
||||
/// The `VStack`'s child order *is* the Tab order and VoiceOver's reading order, so the paragraph above
|
||||
/// is also the accessibility decision.
|
||||
///
|
||||
/// ### Rows, not chips
|
||||
///
|
||||
/// The scope allowed either. The sidebar is **26 characters wide** (`CardWindowMetrics.sidebarWidth`)
|
||||
/// and every other section in it is a vertical stack of full-width rows — Details' key-over-value
|
||||
/// pairs, Attachments' thumbnail rows. A flowing chip cloud in a column that narrow either wraps every
|
||||
/// second label onto its own line (which is a row, drawn worse) or truncates names to four characters.
|
||||
/// So: one label per row, a quiet leading tag glyph, the name, and a remove button at the trailing
|
||||
/// edge — `AttachmentRow`'s silhouette, which is the idiom this column already teaches.
|
||||
///
|
||||
/// ### Present when empty, unlike Details
|
||||
///
|
||||
/// Details disappears on a card with no unknown keys because "there is nothing to teach" there. This
|
||||
/// section stays, with a one-line hint, for **Attachments'** reason instead: it advertises an
|
||||
/// affordance the user has to be able to find. A card window that showed no Labels section until the
|
||||
/// card already had labels would leave the add affordance reachable only from the board's context
|
||||
/// menu — which is a different window.
|
||||
struct CardLabelsSection: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: LabelRecents
|
||||
let cardID: ItemID
|
||||
|
||||
/// The card's labels as the last reload read them — passed in rather than looked up, so this
|
||||
/// section renders exactly what the window's own `Card` value says, like every other section here.
|
||||
let labels: [String]
|
||||
|
||||
/// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels) — a label applied here is
|
||||
/// a gesture *issued in this window*, so its step joins the window's session and reaches board
|
||||
/// history only inside the coarse close step. `CardStyleSection.undo`'s reason verbatim.
|
||||
let undo: CardWindowUndo
|
||||
|
||||
/// Whether the add field is showing. Local view state, `SymbolPicker`'s own posture: the window's
|
||||
/// card cannot vanish out from under its own window, and when it does the window goes with it
|
||||
/// (`CardWindowFate`), so there is no session to keep.
|
||||
@State private var isAdding = false
|
||||
|
||||
/// What is typed in the add field.
|
||||
@State private var draft = ""
|
||||
|
||||
@FocusState private var fieldFocused: Bool
|
||||
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
|
||||
/// The read-only lock and the board's inline-editing rule alike — `CardStyleSection`'s symbol row
|
||||
/// takes the same predicate, and 02-architecture.md's every-entry-point rule does not care which
|
||||
/// entry point.
|
||||
private var isEditable: Bool { store.acceptsBoardMutations }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
|
||||
CardSidebarSectionHeader(title: "Labels") {
|
||||
addAffordance
|
||||
}
|
||||
|
||||
if labels.isEmpty, !isAdding {
|
||||
emptyHint
|
||||
} else {
|
||||
rows
|
||||
}
|
||||
|
||||
if isAdding {
|
||||
addField
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
/// The header's quiet `+` — `CardAttachmentsSection.addAffordance`'s twin, down to the glyph and
|
||||
/// its weight, because two sections one apart in the same column offering "add one of these"
|
||||
/// through two different controls would be the sidebar disagreeing with itself.
|
||||
///
|
||||
/// It **toggles** the field rather than only opening it, so the same key the user reached for puts
|
||||
/// the field away again — Escape does too, but a control that can only open is a control that
|
||||
/// leaves litter.
|
||||
private var addAffordance: some View {
|
||||
Button {
|
||||
isAdding.toggle()
|
||||
if isAdding {
|
||||
fieldFocused = true
|
||||
} else {
|
||||
draft = ""
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!isEditable)
|
||||
.help("Add Label")
|
||||
.accessibilityLabel("Add Label")
|
||||
}
|
||||
|
||||
// MARK: - Empty
|
||||
|
||||
/// One line, `CardAttachmentsSection.emptyHint`'s shape: it names the affordance that is on screen
|
||||
/// rather than a menu path, because unlike Add Attachment… there is no menu-bar row for this yet —
|
||||
/// the other way to reach it is the board's card context menu, which is a different window and no
|
||||
/// use to somebody reading this one.
|
||||
private var emptyHint: some View {
|
||||
Text("No labels. Press + to add one.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
private var rows: some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
ForEach(labels, id: \.self) { name in
|
||||
row(name)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func row(_ name: String) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "tag")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(name)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.help(name)
|
||||
Spacer(minLength: 4)
|
||||
Button {
|
||||
LabelCommand.remove(name, fromCard: cardID, in: store, recents: recents, on: undo)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!isEditable)
|
||||
.help("Remove Label")
|
||||
.accessibilityLabel("Remove \(name)")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(name)
|
||||
}
|
||||
|
||||
// MARK: - Adding
|
||||
|
||||
/// The add field and its suggestions.
|
||||
///
|
||||
/// **Return commits, Escape cancels** — the inline editors' grammar (04-interactions.md ▸ Grammar),
|
||||
/// which is the one commit vocabulary this app has. Committing does *not* close the field: adding
|
||||
/// several labels in a row is the common case, so the field clears and stays, and the `+` (or
|
||||
/// Escape) is what puts it away.
|
||||
@ViewBuilder
|
||||
private var addField: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
TextField("Label", text: $draft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.callout)
|
||||
.focused($fieldFocused)
|
||||
.disabled(!isEditable)
|
||||
.onSubmit { commit() }
|
||||
.onExitCommand {
|
||||
draft = ""
|
||||
isAdding = false
|
||||
}
|
||||
.accessibilityLabel("New Label")
|
||||
|
||||
ForEach(suggestions, id: \.self) { name in
|
||||
Button {
|
||||
apply(name)
|
||||
} label: {
|
||||
Text(name)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(!isEditable)
|
||||
}
|
||||
|
||||
if CardLabelsPicker.createsNewLabel(draft, universe: store.labelIndex.names) {
|
||||
// The only feedback that matters here: everything else in this field applies a label
|
||||
// that already exists somewhere on the board, and this one mints a new word for it.
|
||||
Text("Return creates “\(draft.trimmingCharacters(in: .whitespacesAndNewlines))”")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var suggestions: [String] {
|
||||
CardLabelsPicker.suggestions(
|
||||
for: draft,
|
||||
universe: store.labelIndex.names,
|
||||
existing: labels
|
||||
)
|
||||
}
|
||||
|
||||
/// Return's landing: apply what is typed, clear, stay open.
|
||||
private func commit() {
|
||||
guard CardLabels.normalized(draft) != nil else { return }
|
||||
apply(draft)
|
||||
}
|
||||
|
||||
private func apply(_ name: String) {
|
||||
LabelCommand.add(name, onCard: cardID, in: store, recents: recents, on: undo)
|
||||
draft = ""
|
||||
fieldFocused = true
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,10 @@ struct CardWindowView: View {
|
||||
/// Controls) — app state, not board state, which is why it arrives beside the store rather than
|
||||
/// on it.
|
||||
let recents: StyleRecents
|
||||
/// The app-wide recently-applied labels the Labels section feeds (`LabelRecents`;
|
||||
/// `FrontmatterKeys.labels`) — `recents`' neighbour in every respect, app state rather than board
|
||||
/// state, arriving beside the store for that field's reason exactly.
|
||||
let labelRecents: LabelRecents
|
||||
/// The card's folder on disk — what relative images and links in the body resolve against
|
||||
/// (05 ▸ Preview). `nil` only where a caller has no board root to build it from.
|
||||
let cardFolder: URL?
|
||||
@@ -449,8 +453,8 @@ struct CardWindowView: View {
|
||||
|
||||
// MARK: - Attributes sidebar
|
||||
|
||||
/// The sidebar's sections: **Style, Details, Attachments** — Attachments at the bottom of the
|
||||
/// stack (reordered 2026-08-09, Pipeline card 8f26b029), Actions gone entirely (retired the same
|
||||
/// The sidebar's sections: **Style, Labels, Details, Attachments** — Attachments at the bottom of
|
||||
/// the stack (reordered 2026-08-09, Pipeline card 8f26b029), Actions gone entirely (retired the same
|
||||
/// day, Pipeline card bcd3b323): its Delete and Reveal in Finder are now the card window's own
|
||||
/// toolbar items (`CardToolbar`), reachable from Customize and, for Delete, on by default. This
|
||||
/// is exactly the `VStack`'s child order, so keyboard Tab order and VoiceOver's reading order
|
||||
@@ -462,10 +466,15 @@ struct CardWindowView: View {
|
||||
/// with Actions at the bottom — an owed amendment, tracked on the two cards' own threads rather
|
||||
/// than made here.
|
||||
///
|
||||
/// One of the two remaining sections is conditional, and the condition is the section's own
|
||||
/// rather than a rule restated here: **Details** renders nothing when the card carries no unknown
|
||||
/// frontmatter keys ("shown only when any exist"). Style and Attachments are unconditional, so
|
||||
/// the composition a user learns on one card is the composition they get on the next.
|
||||
/// One of the four sections is conditional, and the condition is the section's own rather than a
|
||||
/// rule restated here: **Details** renders nothing when the card carries no unknown frontmatter
|
||||
/// keys ("shown only when any exist"). Style, Labels and Attachments are unconditional, so the
|
||||
/// composition a user learns on one card is the composition they get on the next.
|
||||
///
|
||||
/// **Labels joined 2026-08-09** (Pipeline card a4462d28), sitting second — see
|
||||
/// `CardLabelsSection`'s own doc comment for why it goes after Style and, load-bearingly, *above*
|
||||
/// Details: `labels` stopped being an unknown key that day, and Details is the section for keys the
|
||||
/// app does not own.
|
||||
///
|
||||
/// The History section that once sat between Details and Actions left with app-managed git
|
||||
/// (strategy/01-git-excision.md, 2026-08-08); View ▸ History (`FutureCommands.swift`) is the only
|
||||
@@ -475,6 +484,16 @@ struct CardWindowView: View {
|
||||
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
|
||||
CardStyleSection(store: store, recents: recents, cardID: card.id, undo: undo)
|
||||
|
||||
// The snapshot's own reading, not a store lookup: this window's `Card` is the value
|
||||
// the last reload produced, and every other section here renders off it.
|
||||
CardLabelsSection(
|
||||
store: store,
|
||||
recents: labelRecents,
|
||||
cardID: card.id,
|
||||
labels: card.labels.value ?? [],
|
||||
undo: undo
|
||||
)
|
||||
|
||||
// The snapshot's own document, not a re-read: the loader parsed this file, unknown
|
||||
// keys and their order included, and `Card` has carried it since (`BoardModel`).
|
||||
CardDetailsSection(rows: CardDetails.rows(of: card.document))
|
||||
|
||||
Reference in New Issue
Block a user