import AppKit import SwiftUI // MARK: - The classes /// **What kind of defect a row is** — the grouping the decision surface is built on /// (01-storage-format.md § Malformed input, settled 2026-07-31: "The surface groups defects **by /// class**: each class section states the defect once, lists the affected files … and carries one /// class-level choice preselected to its default"). /// /// The class is what decides everything a section says and offers: its sentence, its choices, and its /// default. A `BoardLoadError.Reason` is the *walk's* vocabulary — one case per thing that can be /// wrong with a file — and this is the *surface's*: one case per honest answer the app has. /// /// ### Two seatings worth stating /// /// **`malformedSchema` sits with the YAML family** (Redesign Gap bcdd1942, filed): the ruling names /// four classes and this reason is not one of them, but it is the same event to a user — a key they /// typed that Lanework cannot read — and the same honest posture applies, because the app must not /// guess what `schema: banana` was meant to be. So it takes the family's choices exactly: Open in /// Editor + Re-check, plus Skip below the root. /// /// **The retired `order` reasons sit there too.** Nothing throws them any more (01, re-ruled /// 2026-07-31: below the root a missing or unusable `order` reads as append-at-end), and they survive /// in the loader's vocabulary rather than being deleted. A surface that met one anyway would be /// looking at a file only a person can fix, which is precisely what the family means. public enum BoardDefectClass: String, Sendable, Hashable, CaseIterable { /// *Unparseable YAML* — "no app-minted rewrite (the app would be guessing at content)". Plus the /// two seatings above. case unreadableFrontmatter = "unreadable-frontmatter" /// *`schema` newer than the app* — "no honest fix (downgrading risks silent loss)". case newerSchema = "newer-schema" /// *Board root without `index.md`* — the minted repair. case missingBoardIndex = "missing-board-index" /// *Board root missing `schema`* — the stamp. case missingRootSchema = "missing-root-schema" /// The environmental failures — a root that is gone, or that is a file. **Not normally reachable /// here at all**: an attended open whose single defect is environmental retires to welcome like a /// restored one, because there is nothing on disk to repair (`BoardWindowHost`). It is in the /// vocabulary because a *re-walk* can meet one — the surface is on screen, and the user (or their /// agent) deletes the board folder underneath it — and a class with no case would be a crash /// where a blocked surface is the honest answer. case unreachableRoot = "unreachable-root" /// The class a walk's defect belongs to. Exhaustive with no `default`, the vocabulary's standing /// rule: a reason added to the loader without a seat here fails to compile. public init(_ reason: BoardLoadError.Reason) { switch reason { case .unparseableYAML, .malformedSchema, .missingOrder, .malformedOrder: self = .unreadableFrontmatter case .schemaNewerThanApp: self = .newerSchema case .boardRootMissingIndex: self = .missingBoardIndex case .missingSchema: self = .missingRootSchema case .notADirectory, .unreadableRoot: self = .unreachableRoot } } /// The section's heading — what is wrong, said once for the whole class. public var title: String { switch self { case .unreadableFrontmatter: "Frontmatter Lanework can't read" case .newerSchema: "Made by a newer Lanework" case .missingBoardIndex: "This folder has no board index" case .missingRootSchema: "This board doesn't say which format it's in" case .unreachableRoot: "This board can't be read" } } /// The section's explanation — why the choices below it are the only honest ones. Each is the /// ruling's own reasoning said to a user. public var explanation: String { switch self { case .unreadableFrontmatter: "Lanework won't rewrite these files — it would be guessing at your content. Open one, fix it, then Re-check." case .newerSchema: "These were written by a newer Lanework. There's no honest way to read them here — update the app." case .missingBoardIndex: "Lanework can create one, titled after the folder." case .missingRootSchema: "Lanework can stamp it — reliably, because it just read this board as schema 1." case .unreachableRoot: "Nothing here can be repaired from inside Lanework." } } /// **What this class offers for a defect at `path`**, in the order the picker lists them, first /// being the default (01: "one class-level choice preselected to its default"). /// /// **The root restriction is the whole reason `path` is a parameter.** Skip is user-consented /// *tolerance* — the board loads without the item — and there is no board without its root /// (`BoardLoader.unskippablePaths`). So a root defect never gets Skip, whatever its class, and a /// class whose only offer is Skip therefore offers nothing at the root: that is the ruling's "on /// the board root it blocks the whole board (Cancel is the only exit)", falling out of the /// restriction rather than being a rule of its own. public func choices(atPath path: String) -> [BoardDefectChoice] { let isRoot = BoardLoader.unskippablePaths.contains(path) switch self { case .unreadableFrontmatter: return isRoot ? [.editAndRecheck] : [.editAndRecheck, .skip] case .newerSchema: return isRoot ? [] : [.skip] case .missingBoardIndex: return [.repair(.mintBoardIndex)] case .missingRootSchema: return [.repair(.stampSchema)] case .unreachableRoot: return [] } } } // MARK: - The choices /// **What the user decided about one defect** — the surface's whole vocabulary of answers. /// /// Two of the three are *resolutions*: they let the board open. `.editAndRecheck` is deliberately not /// one, and that is the honest half of the ruling rather than a gap — for unparseable frontmatter /// there is no app-mediated outcome at all, so the row's answer is "a person will fix this", and the /// way out of the surface is Re-check (or Cancel). public enum BoardDefectChoice: Sendable, Equatable, Hashable { /// Leave the file exactly as it is; the user opens it, fixes it, and presses Re-check. case editAndRecheck /// **User-consented tolerance** (01: "Skip is user-consented tolerance, loudly marked"): the item /// leaves the board with its whole subtree, the file stays on disk untouched, and the opened /// board carries a notice naming what left. case skip /// The app mints the fix, inside Repair and Open's one write bracket. case repair(BoardRepair) /// **Whether this answer can open the board** — the enabling rule behind Repair and Open: "every /// defect has an actionable resolution (a minted repair or a consented Skip)". public var isActionable: Bool { switch self { case .editAndRecheck: false case .skip, .repair: true } } /// The picker's label — the user's own words for the choice, in the ruling's vocabulary. public var label: String { switch self { case .editAndRecheck: "Fix it myself" case .skip: "Skip it" case .repair(.mintBoardIndex): "Create a board index" case .repair(.stampSchema): "Stamp schema: 1" } } } /// **A fix the app is willing to mint** — the two the ruling grants, and no others. /// /// Both are content-lossless, which is the whole test a repair has to pass here: one writes a file /// that was not there, the other adds a key whose value the walk has already proved. Anything that /// would guess at content is not a repair, it is the `.editAndRecheck` answer above. public enum BoardRepair: String, Sendable, Equatable, Hashable { /// *Board root without `index.md`* — "create a board index (folder-name title, `schema: 1`) — the /// user just opened this folder as a board, and the mint is content-lossless". case mintBoardIndex = "mint-board-index" /// *Board root missing `schema`* — "stamp `schema: 1`, the default: reliable exactly because the /// walk just validated the file against schema 1". case stampSchema = "stamp-schema" } // MARK: - Rows and sections /// One affected file on the surface: the defect, where it is, and what the user has decided about it. public struct BoardDefectRow: Identifiable, Sendable, Equatable { /// The walk's own record — `path` (root-relative) and `reason`, which is the specifics the row /// shows verbatim rather than re-wording. public let defect: BoardLoadError /// What Reveal in Finder selects: the offending file where there is one, and the **folder** /// where there is not (a root with no `index.md` — Finder cannot select a file that does not /// exist, and the folder is what the user needs to look at anyway). public let revealURL: URL /// The file itself, for Open in Editor — `nil` where nothing is there to open. The missing-index /// class is the only one that answers `nil`, and it is why the affordance is a row's fact rather /// than a fixture of every row. public let editURL: URL? /// What this row may answer, first being the class default (`BoardDefectClass.choices(atPath:)`). /// **Empty means blocked**: no repair, no tolerance, nothing the surface can do. public let choices: [BoardDefectChoice] /// The answer standing right now — the class default until the user overrides it, `nil` for a /// blocked row, which has no answer to give. public var choice: BoardDefectChoice? public var defectClass: BoardDefectClass { BoardDefectClass(defect.reason) } /// The path is unique within one walk — a file records at most one defect — so it is the identity /// a re-aggregation matches choices by (`BoardDecisionSurfaceModel.reaggregate`). public var id: String { defect.path } /// Whether this row is what blocks the whole board: it is at an unskippable path and its class /// has nothing to offer there. public var isBlocking: Bool { choices.isEmpty } } /// One class's section: the sentence, the files, and the class-level choice over them. public struct BoardDefectSection: Identifiable, Sendable, Equatable { public let defectClass: BoardDefectClass public var rows: [BoardDefectRow] public var id: String { defectClass.rawValue } public var title: String { defectClass.title } public var explanation: String { defectClass.explanation } /// Every choice any row here can take, in canonical order — what the class-level picker lists. /// /// A union rather than one row's list, because a class can straddle the root: unparseable YAML in /// the root's own `index.md` *and* in a lane's is one class with two offer sets. Applying a /// class-level choice leaves any row that cannot take it alone (`choose(_:inClass:)`), which is /// the honest behaviour — a root row silently gaining Skip would be the one thing the restriction /// exists to prevent. public var choices: [BoardDefectChoice] { var seen: [BoardDefectChoice] = [] for row in rows { for choice in row.choices where !seen.contains(choice) { seen.append(choice) } } return seen } /// The class-level answer — the one every row is giving, or `nil` where they disagree (the user /// has overridden one) or where there is nothing to answer. public var classChoice: BoardDefectChoice? { let answers = Set(rows.compactMap(\.choice)) return answers.count == 1 ? answers.first : nil } /// Whether the per-item disclosure is worth showing: more than one file, and more than one thing /// to say about them. One file's override *is* the class choice, and a class with a single offer /// has no override to make. public var offersPerItemOverride: Bool { rows.count > 1 && choices.count > 1 } } // MARK: - The model /// **The decision surface's state and rules** — everything about it that could go quietly wrong, /// outside SwiftUI so it can be asked without a window (01-storage-format.md § Malformed input, the /// decision surface, settled 2026-07-31). /// /// ### What it is /// /// One walk's `BoardLoadFailure`, grouped by class in walk order, each row preselected to its class /// default, plus the three rules the buttons turn on: which paths a Skip set names, which repairs /// Repair and Open would apply, and whether Repair and Open may be pressed at all. /// /// ### What it deliberately is not /// /// It runs nothing. It does not walk, does not write, and does not know what a window is: the host /// owns the loop — apply the repairs, re-run the walk, re-aggregate or open — because that loop is /// about a *window*, and this is about a decision. That split is what lets the whole vocabulary be /// pinned from a fixture (`BoardDecisionSurfaceTests`) and what keeps "never a second dialog" /// structural: there is one model per open, and Re-check refreshes it in place /// (`reaggregate(_:)`). @MainActor @Observable public final class BoardDecisionSurfaceModel { /// The board this is about — what every row's URL is resolved against, and what the repairs /// write into. public let boardRoot: URL /// The walk's whole aggregate, as it stands. Replaced by each re-aggregation, and it is what /// Cancel's launch-failure message is written from. public private(set) var failure: BoardLoadFailure /// The sections, in **walk order**: classes ordered by where each first appeared in the walk, and /// rows within a class in walk order too. The walk goes root, then lanes in folder-name order /// with their cards inside them, then `.trash/` — so a grouped surface still reads top-down like /// the tree does, which is the whole reason `BoardLoadFailure` keeps its order. public private(set) var sections: [BoardDefectSection] /// Whether a walk or a repair is running right now — the buttons' disabled state. The surface /// stays on screen while it works, because the alternative (back to a spinner, then a surface /// again) would be the chained dialog the ruling forbids, spelled as a flicker. public var isWorking = false /// **The last repair that didn't happen**, shown inline on the surface, or `nil`. /// /// It has nowhere else to go: the banner strip belongs to a board window that has a store, and a /// board being repaired has neither. A failed repair is not fatal — "interrupted batches are /// accepted per the renumber precedent, every intermediate state valid, and a partial repair /// simply re-aggregates on the next walk" — so this is a line, not an exit. public var repairFailure: BoardWriteError? public init(failure: BoardLoadFailure, boardRoot: URL) { self.boardRoot = boardRoot self.failure = failure self.sections = Self.group(failure, boardRoot: boardRoot, keeping: [:]) } // MARK: Re-aggregation /// **A fresh walk's defects, into the same surface** (01: "a disk changed underneath re-aggregates /// into the *same* surface with the fresh defect list, never a chained second dialog"). /// /// Choices survive by path: a defect that is still there keeps the answer the user gave it, and /// anything new takes its class default. That is what makes Repair and Open's partial-failure /// story bearable — the user's other decisions are still standing when the surface comes back. public func reaggregate(_ failure: BoardLoadFailure) { var kept: [String: BoardDefectChoice] = [:] for section in sections { for row in section.rows where row.choice != nil { kept[row.id] = row.choice } } self.failure = failure self.sections = Self.group(failure, boardRoot: boardRoot, keeping: kept) } // MARK: Choosing /// Sets a whole class's answer — the class-level control. Rows that cannot take the choice (a /// root row offered Skip) are left exactly as they were. public func choose(_ choice: BoardDefectChoice, inClass defectClass: BoardDefectClass) { guard let index = sections.firstIndex(where: { $0.defectClass == defectClass }) else { return } for rowIndex in sections[index].rows.indices where sections[index].rows[rowIndex].choices.contains(choice) { sections[index].rows[rowIndex].choice = choice } } /// Sets one file's answer — the per-item override behind the disclosure. A choice the row does /// not offer is ignored rather than trusted. public func choose(_ choice: BoardDefectChoice, forPath path: String) { for sectionIndex in sections.indices { guard let rowIndex = sections[sectionIndex].rows.firstIndex(where: { $0.id == path }) else { continue } guard sections[sectionIndex].rows[rowIndex].choices.contains(choice) else { return } sections[sectionIndex].rows[rowIndex].choice = choice return } } // MARK: The rules the buttons turn on /// The paths the user consented to skip — what the next walk is run with /// (`BoardLoader.load(boardRoot:skipping:)`), and what the opened board's notice is written from. public var skipSet: Set { var paths: Set = [] for section in sections { for row in section.rows where row.choice == .skip { paths.insert(row.id) } } return paths } /// The repairs Repair and Open would apply, **in walk order** — which is the order they run in, /// so a board whose root needs both a minted index and… well, one of the two: the two repairs are /// mutually exclusive today (a root with no `index.md` has no `schema` to be missing), and the /// order is stated for the day a third joins them. public var plannedRepairs: [PlannedRepair] { var planned: [PlannedRepair] = [] for section in sections { for row in section.rows { guard case let .repair(repair) = row.choice else { continue } planned.append(PlannedRepair(path: row.id, repair: repair)) } } return planned } /// One repair, bound to the file it fixes. public struct PlannedRepair: Sendable, Equatable { /// Root-relative, the defect's own path. public let path: String public let repair: BoardRepair } /// **Whether a defect blocks the whole board** — a root defect its class cannot answer for /// (01: "on the board root it blocks the whole board (Cancel is the only exit)"). /// /// The surface still shows everything: a user whose root was written by a newer Lanework is owed /// the whole picture, not a single terse row, because what they do next is decided by how much /// else is wrong. public var isRootBlocked: Bool { sections.contains { $0.rows.contains(where: \.isBlocking) } } /// **Repair and Open's enabling rule**: every defect has an actionable resolution — a minted /// repair or a consented Skip — and nothing blocks the root. /// /// An `.editAndRecheck` row disables it by construction: that answer is "a person will fix this", /// and pressing Repair and Open would either write something the app promised not to write or /// open a board that still refuses to load. public var canRepairAndOpen: Bool { guard !isRootBlocked else { return false } return sections.allSatisfy { section in section.rows.allSatisfy { $0.choice?.isActionable == true } } } /// Every row on the surface, flattened — the count the header states, and what the suites walk. public var rows: [BoardDefectRow] { sections.flatMap(\.rows) } // MARK: Grouping /// The grouping rule: **by class, in walk order** (`sections`). /// /// `keeping` carries the answers a previous aggregation had, by path — empty on a first build. private static func group( _ failure: BoardLoadFailure, boardRoot: URL, keeping kept: [String: BoardDefectChoice] ) -> [BoardDefectSection] { var sections: [BoardDefectSection] = [] for defect in failure.defects { let defectClass = BoardDefectClass(defect.reason) let choices = defectClass.choices(atPath: defect.path) // A kept answer only survives if the row still offers it: a repaired root that came back // as a *different* defect is a different question. let choice = kept[defect.path].flatMap { choices.contains($0) ? $0 : nil } ?? choices.first let row = BoardDefectRow( defect: defect, revealURL: revealURL(for: defect, boardRoot: boardRoot), editURL: editURL(for: defect, boardRoot: boardRoot), choices: choices, choice: choice ) if let index = sections.firstIndex(where: { $0.defectClass == defectClass }) { sections[index].rows.append(row) } else { sections.append(BoardDefectSection(defectClass: defectClass, rows: [row])) } } return sections } /// The folder a defect's path names — the board root for a root defect, the item's folder /// otherwise. `BoardLoadError.path` names the `index.md` (`"/index.md"`), and `"."` is the /// environmental failures' spelling of the root itself. public static func folder(forDefectAt path: String, under boardRoot: URL) -> URL { guard path != "." else { return boardRoot } let relative = (path as NSString).deletingLastPathComponent guard !relative.isEmpty else { return boardRoot } return boardRoot.appendingPathComponent(relative, isDirectory: true) } /// The file a defect names, whether or not it is there. private static func fileURL(for defect: BoardLoadError, boardRoot: URL) -> URL? { guard defect.path != "." else { return nil } return boardRoot.appendingPathComponent(defect.path) } /// Finder selects the file where there is one, and the folder where there is not. private static func revealURL(for defect: BoardLoadError, boardRoot: URL) -> URL { guard let file = fileURL(for: defect, boardRoot: boardRoot), FileManager.default.fileExists(atPath: file.path) else { return folder(forDefectAt: defect.path, under: boardRoot) } return file } /// Open in Editor needs a file that exists — a missing `index.md` has nothing to open, and /// handing its URL to `NSWorkspace` would produce a system error instead of an explanation. private static func editURL(for defect: BoardLoadError, boardRoot: URL) -> URL? { guard let file = fileURL(for: defect, boardRoot: boardRoot), FileManager.default.fileExists(atPath: file.path) else { return nil } return file } } // MARK: - Applying the repairs /// **Repair and Open's write half** — every chosen fix, in one bracket, store-less /// (01-storage-format.md § Malformed input: "**Repair and Open** applies every chosen fix in one /// write bracket — each repaired `index.md` is an ordinary app write (stamps `modified`, clears /// `modified-by`)"). /// /// ### Why it is store-less, and what that costs /// /// There is no `BoardStore` yet — that is the whole situation the surface exists in — so there is no /// `performWrite` to run inside, and with it none of the machinery a write usually gets: no watcher /// bracket (nothing is watching a board that never opened), no banner strip (the window is showing /// this surface), no undo registration (there is no session and no stack), and no read-only lock to /// consult (the probe runs at acquire, which has not happened). Each of those absences is correct /// here rather than merely tolerable. /// /// What it does keep is the **ledger**, bound by hand for the bracket's duration and marked /// wholesale afterwards (`EchoLedger.markAllAsHeal`), because 01 asks for exactly that: "On Pro /// boards the repairs drop heal-marked receipts and commit separately as one repair commit, never /// folded into anyone else's work." The ledger travels to the store that the following walk builds /// (`EchoLedger.adopt`), which is the only reason it can outlive the bracket. /// /// ### Interrupted batches are accepted /// /// "Interrupted batches are accepted per the renumber precedent, every intermediate state valid, and /// a partial repair simply re-aggregates on the next walk." So a failure stops the batch and is /// *returned* rather than thrown away or thrown up: the caller re-walks regardless, the surface comes /// back with whatever is still wrong, and the receipts of what did land come back too. /// /// `@MainActor` because that is where every app write in this app already happens — `performWrite` is /// synchronous on a gesture's own path — and because a repair is one or two small file writes on a /// board the user is looking at. There is nothing here worth an actor hop that the ordinary write /// path does not already do without one. @MainActor public enum BoardRepairRun { /// What one repair pass produced: the heal-marked receipts, and the failure that stopped it. public struct Outcome: Sendable { /// The repair bracket's own ledger — every receipt in it heal-marked, ready for the store's /// ledger to adopt once the walk builds one. public let ledger: EchoLedger /// The repair that did not happen, or `nil`. Everything before it did. public let failure: BoardWriteError? } @discardableResult public static func apply( _ repairs: [BoardDecisionSurfaceModel.PlannedRepair], boardRoot: URL ) -> Outcome { let ledger = EchoLedger() var failure: BoardWriteError? EchoLedger.$current.withValue(ledger) { for planned in repairs { let folder = BoardDecisionSurfaceModel.folder(forDefectAt: planned.path, under: boardRoot) do throws(BoardWriteError) { switch planned.repair { case .mintBoardIndex: // **Folder-name title, `schema: 1`** — the ruling's own parenthesis, and // `createBoard` is already exactly that write. It refuses only when an // `index.md` is already there, which is precisely the defect's negation; and // it seeds `.gitignore`, which is the every-board heal ruled 2026-07-31 and // therefore wanted here too (a board being repaired is a board being brought // up to today's shape). try BoardWriter.createBoard( at: folder, title: AppModel.folderDisplayName(of: folder), operation: .mintBoardIndex ) case .stampSchema: // **An ordinary app write** — which is the ruling's word for it, and what // `updateIndex` means by default: `modified` stamped, `modified-by` cleared, // the round trip preserving every other byte. `kind: .board` is named rather // than derived because a board root's folder name is a Finder document name, // not an identity, so position has no answer to give. try BoardWriter.updateIndex( inItemFolder: folder, kind: .board, operation: .stampSchema ) { document in document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema)) } } } catch { failure = error break } } } // After the bracket, over whatever landed: every receipt this ledger holds came from a repair, // which is what makes the blanket mark honest (`EchoLedger.markAllAsHeal`). A partial batch is // marked too — the files that did land are still the app's own heal work. ledger.markAllAsHeal() return Outcome(ledger: ledger, failure: failure) } } // MARK: - The surface /// **The decision surface** (01-storage-format.md § Malformed input, settled 2026-07-31): /// /// > Blocking means one aggregated surface hosted by the pre-snapshot loading window /// > (02-architecture.md ▸ the loading state) — the loading content transforms in place, never a /// > sheet over a spinner. /// /// So this is a plain view in the board window's content area, replacing `BoardLoadingView` where it /// stood. No sheet, no alert, no second window: the window the user opened is the window that /// explains itself, and ⌘W means Cancel because there is nothing else it could mean. /// /// Everything it renders is `BoardDecisionSurfaceModel`'s; everything it *does* is the host's, as /// three closures. That split is the file's whole shape — see the model. struct BoardDecisionSurface: View { let model: BoardDecisionSurfaceModel /// Applies the chosen repairs and re-runs the walk with the skip set. let onRepairAndOpen: () -> Void /// Re-runs the walk with the current skip set, changing nothing on disk. let onRecheck: () -> Void /// Aborts the open — the window retires and the board lands row-level on welcome. let onCancel: () -> Void /// The identifier a UI suite finds the surface by — **on the heading, not on the container**. /// /// That placement is load-bearing rather than incidental: SwiftUI's `accessibilityIdentifier` /// propagates *down*, and an outer one overwrites every identifier set inside it. A container /// carrying this string would therefore stamp it over the sections, the rows and the three /// buttons — leaving a surface with one identifier repeated a dozen times and nothing a test could /// press. Every identifier in this file is on a leaf for that reason. static let accessibilityIdentifier = "decision-surface" @MainActor private static var pointSize: CGFloat { BoardMetrics.bodyPointSize } @MainActor private static var gutter: CGFloat { BoardMetrics.em(1.4, bodyPointSize: pointSize) } @MainActor private static var stack: CGFloat { BoardMetrics.em(0.8, bodyPointSize: pointSize) } var body: some View { VStack(alignment: .leading, spacing: 0) { header Divider() ScrollView { VStack(alignment: .leading, spacing: Self.gutter) { ForEach(model.sections) { section in sectionView(section) } } .padding(Self.gutter) .frame(maxWidth: .infinity, alignment: .leading) } Divider() footer } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) // `.contain` rather than `.combine`, the banner strip's rule for its reason: the sections and // their rows are each their own element, and fusing a whole repair decision into one // utterance would bury the row a VoiceOver user has to act on. .accessibilityElement(children: .contain) .accessibilityLabel(AccessibilityPhrases.decisionSurfaceLabel) } // MARK: Header and footer private var header: some View { VStack(alignment: .leading, spacing: Self.stack / 2) { Text(AccessibilityPhrases.decisionSurfaceLabel) .font(.headline) .accessibilityIdentifier(Self.accessibilityIdentifier) Text(AccessibilityPhrases.decisionSurfaceSummary(defects: model.rows.count)) .font(.callout) .foregroundStyle(.secondary) if let failure = model.repairFailure { // The pre-store failure's only surface — see `BoardDecisionSurfaceModel.repairFailure`. // Phrased by `BannerCenter`, like every other write failure in the app. Label(BannerCenter.headline(for: failure), systemImage: "exclamationmark.triangle.fill") .font(.callout) .foregroundStyle(.red) .accessibilityIdentifier("decision-repair-failure") } } .frame(maxWidth: .infinity, alignment: .leading) .padding(Self.gutter) } private var footer: some View { HStack(spacing: Self.stack) { if model.isWorking { ProgressView() .controlSize(.small) .accessibilityLabel(AccessibilityPhrases.boardLoading) } Spacer(minLength: 0) Button("Cancel", role: .cancel, action: onCancel) .keyboardShortcut(.cancelAction) .accessibilityIdentifier("decision-cancel") Button("Re-check", action: onRecheck) .accessibilityIdentifier("decision-re-check") Button("Repair and Open", action: onRepairAndOpen) .keyboardShortcut(.defaultAction) .disabled(!model.canRepairAndOpen) .accessibilityIdentifier("decision-repair-and-open") } .disabled(model.isWorking) .padding(Self.gutter) } // MARK: One class @ViewBuilder private func sectionView(_ section: BoardDefectSection) -> some View { VStack(alignment: .leading, spacing: Self.stack) { VStack(alignment: .leading, spacing: Self.stack / 3) { Text(section.title) .font(.headline) // On the heading rather than on the section — see `accessibilityIdentifier`. .accessibilityIdentifier("decision-section-\(section.id)") Text(section.explanation) .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } if section.choices.count > 1 { Picker("What to do", selection: classChoiceBinding(section)) { ForEach(section.choices, id: \.self) { choice in Text(choice.label).tag(Optional(choice)) } // **Only while the class actually is mixed.** A per-item override leaves the // class with no single answer, and a picker with no matching tag would silently // show the first choice instead — which would say the override had not happened. // Offering it the rest of the time would be a fourth radio button that does // nothing. if section.classChoice == nil { Text("Mixed").tag(Optional.none) } } .pickerStyle(.radioGroup) .accessibilityIdentifier("decision-class-choice-\(section.id)") } else if let only = section.choices.first { Text(only.label) .font(.callout) } VStack(alignment: .leading, spacing: Self.stack / 2) { ForEach(section.rows) { row in rowView(row) } } if section.offersPerItemOverride { DisclosureGroup("Choose for each file") { VStack(alignment: .leading, spacing: Self.stack / 2) { ForEach(section.rows) { row in overrideView(row) } } .padding(.top, Self.stack / 2) } .font(.callout) .accessibilityIdentifier("decision-overrides-\(section.id)") } } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .contain) .accessibilityLabel(section.title) } // MARK: One file private func rowView(_ row: BoardDefectRow) -> some View { HStack(alignment: .firstTextBaseline, spacing: Self.stack) { VStack(alignment: .leading, spacing: 0) { Text(row.defect.path) .font(.callout.monospaced()) Text(row.defect.reason.description) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([row.revealURL]) } .buttonStyle(.link) .font(.callout) if let editURL = row.editURL { Button("Open in Editor") { NSWorkspace.shared.open(editURL) } .buttonStyle(.link) .font(.callout) } } .accessibilityIdentifier("decision-row-\(row.id)") // One element per file, path and reason together, so a VoiceOver user hears *which* file and // *what is wrong with it* as one sentence rather than as two neighbouring labels. The two // buttons stay inside it as custom actions and are Tab stops besides — the banner row's rule. .accessibilityElement(children: .combine) .accessibilityLabel( AccessibilityPhrases.decisionRowLabel(path: row.defect.path, reason: row.defect.reason.description) ) } private func overrideView(_ row: BoardDefectRow) -> some View { HStack(alignment: .firstTextBaseline, spacing: Self.stack) { Text(row.defect.path) .font(.caption.monospaced()) .frame(maxWidth: .infinity, alignment: .leading) Picker("What to do", selection: rowChoiceBinding(row)) { ForEach(row.choices, id: \.self) { choice in Text(choice.label).tag(Optional(choice)) } } .labelsHidden() .fixedSize() .disabled(row.choices.count < 2) } .accessibilityIdentifier("decision-override-\(row.id)") } // MARK: Bindings private func classChoiceBinding(_ section: BoardDefectSection) -> Binding { Binding( get: { section.classChoice }, set: { choice in guard let choice else { return } model.choose(choice, inClass: section.defectClass) } ) } private func rowChoiceBinding(_ row: BoardDefectRow) -> Binding { Binding( get: { row.choice }, set: { choice in guard let choice else { return } model.choose(choice, forPath: row.id) } ) } }