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