The decision surface — a refused open becomes a live repair, in place

Phase 3 of the decision surface, completing the card (01 ▸ Malformed
input, settled 2026-07-31). An attended open's fail-fast walk transforms
the loading window's content into one aggregated surface — never a
sheet, never a chain: defects grouped by class, each class stated once
with its files listed (Reveal in Finder + Open in Editor per row), a
class-level default preselected, per-item override behind a disclosure.
Only honest choices: YAML and malformed-schema get Editor + Re-check
(Skip below the root); newer-than-app gets Skip alone and blocks the
board at the root; the two root repairs — minted index, schema: 1 stamp
— are defaults. Repair and Open applies fixes in one store-less write
bracket and re-walks: clean proceeds, remainder re-aggregates into the
same surface. Cancel and ⌘W retire to welcome's row; restored opens
never see the surface at all (OpenOrigin rides the PendingOpen carrier).

Skips are per-open consent that rides the session — the store retains
the skip set and every reload passes it — and the opened board posts a
warning-tone notice naming what was left out, each item's Reveal riding
the banner strip's new reveal control. On Pro boards the repair bracket
binds its own EchoLedger, heal-marks everything, and the store adopts it
before the committer starts, so repairs land as one separate commit
authored Lanework Integrity — pinned end to end. Also fixed en route: a
retired loading window left its close interception installed and
returned false from windowShouldClose forever, blocking quit.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 10:52:02 -04:00
parent 0933ac1b01
commit 31fee00c73
17 changed files with 2448 additions and 107 deletions
+85 -15
View File
@@ -190,6 +190,32 @@ public final class ScopedAccess {
}
}
// MARK: - OpenOrigin
/// **Whether a person asked for this board right now** (01-storage-format.md § Malformed input, the
/// decision surface, settled 2026-07-31):
///
/// > It appears on **attended opens only** (welcome click, File Open, Finder): restoration
/// > failures keep the retire-to-welcome-row landing, and the row's retry click is the attended open
/// > that then shows the surface repair is an attended act, and launch never chains dialogs.
///
/// So this is not a description of *where* an open came from it is the one bit that decides what a
/// failed one does. A closed two-case vocabulary rather than a `Bool` because the sentence a reader
/// needs at the branch is "restored boards retire", not "`isAttended` is false".
///
/// **Attended is the default everywhere**, and that is load-bearing: welcome's rows, File Open,
/// the Finder open, Duplicate's follow-on open and the template chooser's are all somebody clicking
/// something. Exactly one caller says otherwise launch restoration (`RestoreBootstrapView`) which
/// makes "did a person ask for this" a question one place answers rather than a flag every call site
/// has to get right.
public enum OpenOrigin: Sendable, Equatable {
/// A person just asked for this board.
case attended
/// Launch restoration reopening what was open last time. Nobody is waiting on it, and a failure
/// lands on welcome's row rather than in a surface.
case restored
}
// MARK: - AppModel
/// The app's one piece of cross-window state: which boards are open, which card windows belong to
@@ -595,15 +621,31 @@ public final class AppModel {
// MARK: Pending opens
/// The security-scoped URL a board window is about to be built from, stashed between
/// `openBoard(at:)` and the host's first appearance.
/// Everything `openBoard(at:origin:)` knows that a `BoardWindowRef` cannot carry.
///
/// The handoff exists because a window value has to be `Codable` and a scoped URL is not a
/// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone
/// unless something carried it across. The host claims it on appear; an unclaimed entry (a window
/// that never opened) leaks one scope until quit, which is the cheapest failure available here.
/// **One struct rather than two parallel dictionaries** (the shape this replaced was
/// `pendingAccess` alone): both facts are stashed by the same call, claimed by the same call, and
/// meaningless apart a window that found an origin but no access, or the reverse, would be a
/// bug with no honest reading. Keeping them in one value makes "they are always in step" true by
/// construction instead of by two `removeValue`s that must not drift.
private struct PendingOpen {
/// The security-scoped URL the board will be built from, or `nil` where the open needed no
/// scope. See `ScopedAccess`: a `URL` rebuilt from `ref.path` grants nothing.
let access: ScopedAccess?
/// Whether a person asked for this board what a failed open branches on (`OpenOrigin`).
let origin: OpenOrigin
}
/// What a board window is about to be built from, stashed between `openBoard(at:origin:)` and the
/// host's first appearance.
///
/// The handoff exists because a window value has to be `Codable` and neither of these is a
/// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone,
/// and the origin was never in the ref at all. The host claims both on appear; an unclaimed entry
/// (a window that never opened) leaks one scope until quit, which is the cheapest failure
/// available here.
@ObservationIgnored
private var pendingAccess: [BoardWindowRef: ScopedAccess] = [:]
private var pendingOpens: [BoardWindowRef: PendingOpen] = [:]
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model")
@@ -677,8 +719,19 @@ public final class AppModel {
/// **Called before any scene has appeared, and that's fine.** A cold launch's Finder-open can
/// reach here before `windowOpener` is captured; the URL joins `pendingOpenURLs` and this same
/// method runs again for it once `captureWindowActions` has something to open it with.
public func openBoard(at url: URL) {
///
/// - Parameter origin: whether a person asked for this board right now (`OpenOrigin`) the one
/// bit a *failed* open branches on (01-storage-format.md § Malformed input: the decision
/// surface "appears on attended opens only"). `.attended` by default, which is every caller but
/// launch restoration: welcome's rows, File Open, the Finder open, the template chooser's
/// follow-on, Duplicate's. The default is the rule stated once rather than repeated five times.
public func openBoard(at url: URL, origin: OpenOrigin = .attended) {
guard let windowOpener else {
// **The queue carries no origin, and needs none**: it is reachable only *before any scene
// exists*, which is the cold-launch Finder/URL open and nothing else restoration
// captures the window actions as its first act (`RestoreBootstrapView.restore`) and so can
// never queue. Everything in here is therefore attended, which is what the replay's
// default gives it.
pendingOpenURLs.append(url)
return
}
@@ -689,11 +742,22 @@ public final class AppModel {
}
let ref = BoardWindowRef(url: url)
stashPendingOpen(for: ref, url: url, origin: origin)
windowOpener(id: WindowID.board, value: ref)
}
/// Stashes what the window about to appear will claim the handoff's write half.
///
/// A method of its own rather than two lines inside `openBoard(at:origin:)` because that method
/// cannot run without SwiftUI's `OpenWindowAction`, which is not a thing a test can construct: the
/// carrier would otherwise be the one part of the attendance plumbing with no headless proof, and
/// an origin that quietly stopped travelling would look exactly like the app before this
/// milestone.
func stashPendingOpen(for ref: BoardWindowRef, url: URL, origin: OpenOrigin) {
// Replacing a stash for the same ref would strand the old scope; there is no such case today
// (an unopened window's ref is not reachable), but stopping the loser is free.
pendingAccess.removeValue(forKey: ref)?.stop()
pendingAccess[ref] = ScopedAccess(url)
windowOpener(id: WindowID.board, value: ref)
pendingOpens.removeValue(forKey: ref)?.access?.stop()
pendingOpens[ref] = PendingOpen(access: ScopedAccess(url), origin: origin)
}
/// Shows or focuses the welcome window. Its own scene id, so this works with no windows at
@@ -748,10 +812,16 @@ public final class AppModel {
sessions[ref]
}
/// Claims the scoped URL `openBoard(at:)` stashed for this window, or `nil` if it opened by some
/// other route. Claiming removes it: the session owns the balance from here.
func claimPendingAccess(for ref: BoardWindowRef) -> ScopedAccess? {
pendingAccess.removeValue(forKey: ref)
/// Claims what `openBoard(at:origin:)` stashed for this window. Claiming removes it: the session
/// owns the scope's balance from here.
///
/// A window that opened by some other route a route that never went through `openBoard` gets
/// no scope and reads as **attended**, which is the safe direction: the worst an attended reading
/// can do to a failed open is offer the user a repair they did not ask for, where the reverse
/// would silently retire a board somebody just double-clicked.
func claimPendingOpen(for ref: BoardWindowRef) -> (access: ScopedAccess?, origin: OpenOrigin) {
guard let pending = pendingOpens.removeValue(forKey: ref) else { return (nil, .attended) }
return (pending.access, pending.origin)
}
/// Starts a board's session the board window's host calls this once its load has succeeded.
+279 -16
View File
@@ -86,10 +86,39 @@ struct BoardWindowHost: View {
/// that precedes `start()`.
@State private var recordID: UUID?
/// This open's security-scoped access, claimed in `start()` and held until the session takes it
/// over or the open ends.
///
/// `@State` rather than a local in `start()` because the decision surface outlives that call: a
/// repair *writes into the board*, and a scope released when `start()` returned would be released
/// exactly before the one write that needs it. Every exit balances it the session adopts it,
/// or Cancel and the failure path stop it.
@State private var access: ScopedAccess?
/// Whether a person asked for this board (`OpenOrigin`) claimed beside the access, and read by
/// exactly one branch: what a failed walk does.
@State private var origin: OpenOrigin = .attended
/// The board's URL as this open resolved it the scoped one where there is one. Held for the
/// surface's sake, which re-walks and repairs against it long after `start()` has returned.
@State private var boardURL: URL?
/// **The repair bracket's ledger**, held between Repair and Open's writes and the store that the
/// following walk builds (`BoardRepairRun`, `EchoLedger.adopt`).
///
/// It cannot live anywhere else: the repairs run before a store exists and the receipts have to
/// reach that store's ledger before `beginSession` composes Pro's committer, or the app's own
/// repair commits as `Lanework External`. Cleared once adopted.
@State private var repairLedger: EchoLedger?
@State private var phase: Phase = .opening
private enum Phase {
case opening
/// **The walk refused and a person is looking at it** the decision surface, in the loading
/// window's own content area (01-storage-format.md § Malformed input, settled 2026-07-31:
/// "the loading content transforms in place, never a sheet over a spinner").
case deciding(BoardDecisionSurfaceModel)
case open(BoardStore)
/// The load failed; this window is on its way out and must not try again.
case failed
@@ -122,6 +151,15 @@ struct BoardWindowHost: View {
// when `phase` becomes `.open` a snap, which is what assigning outside `withAnimation`
// means here.
BoardLoadingView(indicator: loading)
case let .deciding(model):
// **In place.** The same content area the spinner was in, with no transition of its own:
// 02's snap, read for the surface that arrives instead of a snapshot.
BoardDecisionSurface(
model: model,
onRepairAndOpen: { repairAndOpen(model) },
onRecheck: { recheck(model) },
onCancel: { cancelDecision(model, closingWindow: false) }
)
case .failed:
// Nothing to render and nothing worth animating: this window is dismissing itself.
Color.clear
@@ -246,9 +284,13 @@ struct BoardWindowHost: View {
private func start() async {
guard case .opening = phase else { return }
// Claimed even on the failure path: an unclaimed stash is a scope nobody balances.
let access = appModel.claimPendingAccess(for: ref)
let url = access?.url ?? ref.url
// Claimed even on the failure path: an unclaimed stash is a scope nobody balances. The origin
// rides along one claim, one dictionary (`AppModel.claimPendingOpen`).
let claimed = appModel.claimPendingOpen(for: ref)
access = claimed.access
origin = claimed.origin
let url = claimed.access?.url ?? ref.url
boardURL = url
// Record before load (settled, 02 § Per-board app state). `displayName` is omitted an
// existing record's cached title survives untouched, and a brand-new one takes the folder
@@ -263,9 +305,23 @@ struct BoardWindowHost: View {
configureLoadingWindow(recordID: recordID)
loading.begin()
await attemptOpen(url: url, recordID: recordID, skipping: [])
}
/// **One walk, and what it lands in** the loop Repair and Open and Re-check re-enter.
///
/// It is a method rather than the tail of `start()` because the surface's two buttons "re-run the
/// whole walk" (01-storage-format.md § Malformed input) and must land in exactly the places this
/// lands: a clean walk proceeds into the ordinary open, and a walk that still refuses
/// re-aggregates into the *same* surface. Sharing the body is what makes "never a chained second
/// dialog" structural rather than remembered.
///
/// - Parameter skipping: the surface's consented skips, empty on a first attempt. It reaches the
/// walk *and* the store, which retains it for the session (`BoardStore.skippedPaths`).
private func attemptOpen(url: URL, recordID: UUID, skipping: Set<String>) async {
let store: BoardStore
do throws(BoardLoadFailure) {
guard let acquired = try await appModel.storeRegistry.acquireOffMain(url) else {
guard let acquired = try await appModel.storeRegistry.acquireOffMain(url, skipping: skipping) else {
// W landed while the walk was running, and the walk has now finished into a result
// nobody wants (`acquireOffMain`, discard-on-completion). The window is already
// closing and the registry kept nothing, so the only thing left to balance is this
@@ -273,27 +329,39 @@ struct BoardWindowHost: View {
// after the load, so a cancelled open never set one the very reason it lives there.
Self.logger.debug("board open cancelled during its walk")
loading.end()
access?.stop()
releaseAccess()
return
}
store = acquired
} catch {
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
loading.end()
access?.stop()
phase = .failed
appModel.recordLaunchFailure(path: ref.path, message: error.description)
// The record above just changed the registry welcome, about to appear, must not
// render the stale list `AppModel` cached before this open began, or the failure would
// fall through to the unmatched-failures list for want of a row that already exists.
appModel.refreshRecents()
openWindow(id: WindowID.welcome)
dismissWindow(id: WindowID.board, value: ref)
handleWalkFailure(error, url: url, recordID: recordID)
return
}
// **The window retired while this walk was in flight** Cancel (or W) pressed during a
// Re-check, whose walk then landed successfully. The board must not open behind a window that
// has already gone to welcome, and the reference `acquireOffMain` took has to go back or the
// registry would hold a watcher for a board nobody is showing.
if case .failed = phase {
Self.logger.debug("a walk landed after the open was cancelled — releasing it")
appModel.storeRegistry.release(store)
releaseAccess()
return
}
loading.end()
// **The repair's receipts, into the board's own ledger before the session composes**
// (01: "On Pro boards the repairs drop heal-marked receipts and commit separately as one
// repair commit"). `beginSession` is where Pro's committer is built and started, and the
// committer harvests the ledger it is handed; receipts adopted after that line would be
// receipts the repair commit never sees, and the app's own repair would be authored
// `Lanework External`.
if let repairLedger {
store.echoes.adopt(repairLedger)
self.repairLedger = nil
}
// The load succeeded the frontmatter can be trusted now, so it replaces whatever
// provisional or stale name the record above was carrying. Through `syncDisplayState`,
// deliberately not a second `recordOpen`: this is a display-state refresh, not a second
@@ -307,15 +375,210 @@ struct BoardWindowHost: View {
)
appModel.boardRegistry.setOpenNow(id: recordID)
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
// The session owns the balance from here (`AppModel.beginSession`), so this window must not
// stop it on any later path.
access = nil
phase = .open(store)
configureWindow(store: store, recordID: recordID)
postSkipNoticeIfNeeded(store: store, skipping: skipping)
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
// when welcome is not open, which is the ordinary case.
dismissWindow(id: WindowID.welcome)
}
/// **The attendance branch** (01-storage-format.md § Malformed input, settled 2026-07-31): the
/// surface "appears on attended opens only restoration failures keep the retire-to-welcome-row
/// landing".
///
/// Three outcomes, and the third is the new one:
///
/// - **A restored open retires**, exactly as it did before this milestone. Nobody is sitting in
/// front of a launch that reopened four boards, and "launch never chains dialogs".
/// - **An attended open whose failure is environmental retires too** a root that is gone, or a
/// root that is a file. There is nothing on disk to repair, so a surface would offer the user a
/// decision with no choices in it; welcome's row says the same thing in one line, which is
/// where it belonged already. (The carve-out the ruling implies rather than states Redesign
/// Gap 87cd782a.)
/// - **Anything else transforms into the surface**, in place, in this window.
///
/// A surface is *entered* rather than shown: it holds this window's access, its record, and its
/// URL for as long as the user is deciding, which is why none of the three is released here.
private func handleWalkFailure(_ error: BoardLoadFailure, url: URL, recordID: UUID) {
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
// Retired while this walk ran (Cancel during a Re-check): the failure has already been
// recorded and the window is on its way out. A second `retire` would post the row twice.
if case .failed = phase { return }
guard Self.landing(for: error, origin: origin) == .decide else {
loading.end()
retire(message: error.description)
return
}
// Already deciding: this is a Re-check or a Repair and Open landing, and it re-aggregates in
// place the same model, the same window, no second dialog.
if case let .deciding(model) = phase {
model.reaggregate(error)
model.isWorking = false
return
}
loading.end()
phase = .deciding(BoardDecisionSurfaceModel(failure: error, boardRoot: url))
// W now means Cancel (01: the surface's own exit), replacing the loading half's
// cancel-the-walk closure a slot rather than a branch, `configureWindow`'s posture.
windowController.onCloseRequested = {
guard case let .deciding(model) = phase else { return }
cancelDecision(model, closingWindow: true)
}
}
/// Where a refused walk lands.
enum FailureLanding: Equatable {
/// Welcome, on the board's own recents row today's landing, unchanged.
case retire
/// The decision surface, in this window.
case decide
}
/// **The attendance branch as a pure rule** two facts in, one landing out, provable without a
/// window (`BoardDecisionSurfaceTests`).
///
/// It is a static rather than an `if` inside `handleWalkFailure` because it is the ruling's own
/// sentence and the one thing about this milestone that a regression would make silently wrong:
/// a launch that started showing surfaces would chain dialogs across four restored boards, and an
/// attended open that stopped showing one would look exactly like the app before this milestone.
static func landing(for failure: BoardLoadFailure, origin: OpenOrigin) -> FailureLanding {
guard origin == .attended else { return .retire }
return isEnvironmental(failure) ? .retire : .decide
}
/// **Nothing on disk to repair** the environmental carve-out's predicate.
///
/// A single defect, and that defect is a fact about the *root itself* rather than about a file
/// inside it: `BoardLoader` throws these immediately, before any walk, precisely because there is
/// nothing to walk. The single-defect check is stated rather than assumed the loader's
/// environmental throws are single by construction, and a future aggregate carrying one *among*
/// repairable defects should show the surface, because the rest of it is still actionable.
static func isEnvironmental(_ failure: BoardLoadFailure) -> Bool {
guard failure.defects.count == 1 else { return false }
switch failure.primary.reason {
case .unreadableRoot, .notADirectory:
return true
case .boardRootMissingIndex, .unparseableYAML, .missingSchema, .malformedSchema,
.schemaNewerThanApp, .missingOrder, .malformedOrder:
return false
}
}
// MARK: - The surface's three buttons
/// **Repair and Open**: apply every chosen fix in one write bracket, then re-run the whole walk
/// with the skip set (01-storage-format.md § Malformed input).
///
/// The repairs are store-less by necessity and heal-marked by rule (`BoardRepairRun`); the walk
/// that follows is the ordinary one, so a clean result proceeds into the ordinary open and a
/// dirty one re-aggregates here. A repair that failed does not abort anything the batch stops,
/// the failure shows on the surface, and the walk runs anyway, because "a partial repair simply
/// re-aggregates on the next walk".
private func repairAndOpen(_ model: BoardDecisionSurfaceModel) {
guard let url = boardURL, let recordID, !model.isWorking else { return }
model.isWorking = true
model.repairFailure = nil
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: url)
model.repairFailure = outcome.failure
// Held for the store the walk below may build see `repairLedger`. Merged with anything an
// earlier pass left, so two rounds of repair both reach the commit.
if let existing = repairLedger {
existing.adopt(outcome.ledger)
} else {
repairLedger = outcome.ledger
}
let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
openWalk.adopt(walk)
}
/// **Re-check**: re-run the whole walk with the current skip set, changing nothing on disk
/// (01: "a disk changed underneath re-aggregates into the *same* surface with the fresh defect
/// list a clean walk proceeds to the first snapshot").
private func recheck(_ model: BoardDecisionSurfaceModel) {
guard let url = boardURL, let recordID, !model.isWorking else { return }
model.isWorking = true
let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
openWalk.adopt(walk)
}
/// **Cancel** and W, which means exactly this while the surface is up (01: "**Cancel** aborts
/// the open: the window retires and the board lands row-level on welcome, record-before-load
/// unchanged").
///
/// It is the failure path's own sequence, run against the aggregate the surface was showing: the
/// record was minted before the walk, so the row is already waiting for this message.
///
/// - Parameter closingWindow: true when AppKit asked (W), where the interception has to be
/// released for the close to complete. The button's own press dismisses through SwiftUI.
private func cancelDecision(_ model: BoardDecisionSurfaceModel, closingWindow: Bool) {
// A walk may still be in flight behind the surface (Cancel during a Re-check). Its landing is
// guarded by `phase`, which this sets to `.failed`; cancelling as well means the open is not
// merely ignored but abandoned.
openWalk.cancel()
retire(message: model.failure.description)
if closingWindow {
windowController.closeAfterFlush()
}
}
/// The retire-to-welcome landing, in one place: the sequence a failed restore has always had, and
/// the sequence Cancel now shares with it.
private func retire(message: String) {
releaseAccess()
phase = .failed
// **The interception has to go with the phase.** `windowShouldClose` returns `false` whenever
// a closure is installed that is how the close flush gets its turn so a retired window
// whose closure had nothing left to do would *refuse every close request for the rest of the
// app's life*, quit included. There is nothing to intercept once this window is on its way to
// welcome: no store, no session, no decision.
windowController.onCloseRequested = nil
appModel.recordLaunchFailure(path: ref.path, message: message)
// The record minted before the walk just changed the registry welcome, about to appear,
// must not render the stale list `AppModel` cached before this open began, or the failure
// would fall through to the unmatched-failures list for want of a row that already exists.
appModel.refreshRecents()
openWindow(id: WindowID.welcome)
dismissWindow(id: WindowID.board, value: ref)
}
private func releaseAccess() {
access?.stop()
access = nil
}
/// **The skip notice** (01: "the opened board carries a warning-tone notice naming the skipped
/// items, each with Reveal in Finder").
///
/// Written from the walk's own `LoadWarning.userSkipped` entries rather than from the surface's
/// skip set, deliberately: what the notice owes the user is what actually left the board, and a
/// skip for a defect that repaired itself between the decision and the walk names nothing.
///
/// **Only the open that carried the skips posts it.** A second window onto the same board
/// acquires the store that already exists, whose warnings still describe the first open and a
/// notice repeated per window would be the app reporting one decision twice. `skipping` is
/// non-empty for exactly the open that made the decision.
private func postSkipNoticeIfNeeded(store: BoardStore, skipping: Set<String>) {
guard !skipping.isEmpty else { return }
let root = store.rootURL
let items = store.loadWarnings.compactMap { warning -> RevealTarget? in
guard case let .userSkipped(path) = warning else { return nil }
return RevealTarget(path: path, url: root.appendingPathComponent(path))
}
store.banners.postSkippedOnOpen(items)
}
/// **The half of the wiring a window needs before it has a board** everything here is about
/// the *window*, and nothing here mentions the store, which is exactly the split
/// 02-architecture.md's loading state forces: this runs before the walk, and
+11 -4
View File
@@ -88,7 +88,12 @@ struct RestoreBootstrapView: View {
for board in appModel.boardRegistry.restorables() {
switch board {
case let .available(_, url):
appModel.openBoard(at: url)
// **The one restored open in the app** (01-storage-format.md § Malformed input, the
// decision surface): a board that fails here keeps today's retire-to-welcome-row
// landing "launch never chains dialogs", and nobody is sitting in front of a
// restoration waiting to repair four boards at once. The row's retry click is the
// attended open that then shows the surface.
appModel.openBoard(at: url, origin: .restored)
attempted += 1
case let .unavailable(record):
Self.logger.error("a flagged board could not be restored — its bookmark no longer resolves")
@@ -109,9 +114,11 @@ struct RestoreBootstrapView: View {
///
/// **Which board is the launch arguments' to say** (`UITestLaunch.variant`), and this method does
/// not care: the malformed variant is built and opened exactly like the other two, and its
/// failure arrives one layer down as the *loader's* a board window that records fail-fast's own
/// sentence and dismisses itself (`BoardWindowHost.start`). Special-casing it here would replace
/// the sentence under test with a sentence about the fixture.
/// failure arrives one layer down as the *loader's*. It opens **attended**, like every other
/// board a person asks for, so its refusal transforms the loading window into the decision surface
/// (`BoardWindowHost.handleWalkFailure`) rather than retiring which is precisely what the
/// fail-fast UI pass is there to see. Special-casing it here would replace the behaviour under
/// test with a behaviour about the fixture.
///
/// **A failure to *build* lands on welcome as an ordinary launch failure**, with the fixture's own
/// path on it. That is deliberate: a suite whose fixture failed to build would otherwise audit an
+12
View File
@@ -300,6 +300,18 @@ public final class GitAutoCommitter {
/// which is not a shortcut but the doctrine: the ledger is empty because the app was not running,
/// and "the app never vouches for changes it didn't witness".
public func start() {
// **The ledger may already hold something, and exactly once it does.** An ordinary board's
// ledger is empty here the app has not written to it, which is the whole of the
// launch-catch-up doctrine so this harvest costs a dictionary copy of nothing.
//
// The exception is a board the **decision surface repaired** (01-storage-format.md
// § Malformed input): those writes happened before this board had a store at all, and their
// heal-marked receipts were adopted into the store's ledger a moment ago
// (`EchoLedger.adopt`, `BoardWindowHost`). Without this line the only harvest is at a write
// bracket's close, and no bracket has closed so the debounce this arms would find the
// repaired files unvouched-for and author the app's own repair `Lanework External`, which is
// the one misattribution the mechanism exists to prevent.
harvest()
arm()
}
+123 -7
View File
@@ -130,10 +130,45 @@ public struct LossBanner: Identifiable, Sendable, Equatable {
/// When the loss happened the sort key for "newest first within a class".
public let occurredAt: Date
public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) {
/// **What this row can show the user in Finder**, empty for every loss row that has nothing to
/// point at which is all of them but one.
///
/// It exists for the skip notice (01-storage-format.md § Malformed input, ruled 2026-07-31: "the
/// opened board carries a warning-tone notice naming the skipped items, **each with Reveal in
/// Finder**"). The affordance is per *item* while the row is one line, so the targets ride the
/// row's data and the strip renders one control over them (`BannerRowControl.reveal`) a button
/// for a sole item, a menu for several. Carrying them here rather than in a row case of their own
/// keeps the skip notice in the loss class the ruling puts it in.
public let reveals: [RevealTarget]
public init(
id: UUID = UUID(),
message: String,
occurredAt: Date = Date(),
reveals: [RevealTarget] = []
) {
self.id = id
self.message = message
self.occurredAt = occurredAt
self.reveals = reveals
}
}
/// One file a banner row can reveal in Finder.
///
/// `path` is what the user reads the board-root-relative spelling `BoardLoadError.path` carries and
/// the decision surface's row already showed them and `url` is what Finder selects. The two are
/// carried together rather than derived from each other because only the producer holds the board
/// root, and a row that rebuilt a URL from a string would be a second answer to where the board is.
public struct RevealTarget: Identifiable, Sendable, Equatable {
public let path: String
public let url: URL
public var id: String { path }
public init(path: String, url: URL) {
self.path = path
self.url = url
}
}
@@ -327,7 +362,7 @@ public enum BannerRow: Identifiable, Sendable {
}
}
/// **This row's buttons, in the order Tab visits them** Cancel, then Dismiss.
/// **This row's buttons, in the order Tab visits them** Cancel, then Reveal, then Dismiss.
///
/// It exists because 10-accessibility.md Full Keyboard Access rules the banner's buttons in by
/// name (2026-07-29): "'Every control' is literal and includes banner-row buttons a Dismiss or
@@ -338,14 +373,18 @@ public enum BannerRow: Identifiable, Sendable {
/// posture the rest of this type already takes ("the per-kind affordances hang off the row's
/// data, not off separate views").
///
/// No row has both today: the two conditions are disjoint by construction (only an in-progress
/// row cancels, and an in-progress row is never dismissable). The order is stated anyway, since
/// it is the Tab order the moment one does.
/// Cancel and Dismiss are disjoint by construction (only an in-progress row cancels, and an
/// in-progress row is never dismissable), so the pair that actually co-occurs is **Reveal then
/// Dismiss** the skip notice's shape. Reveal comes first because it is the row's *content*
/// affordance and Dismiss is its lifecycle one: the same reason Cancel precedes Dismiss.
public var controls: [BannerRowControl] {
var controls: [BannerRowControl] = []
if case let .inProgress(operation) = self, let cancel = operation.cancel {
controls.append(.cancel(cancel))
}
if case let .loss(loss) = self, !loss.reveals.isEmpty {
controls.append(.reveal(loss.reveals))
}
if let dismissID {
controls.append(.dismiss(dismissID))
}
@@ -370,10 +409,26 @@ public enum BannerRowControl: Identifiable, Sendable {
/// Clear this row, by the id `BannerCenter.dismiss(_:)` takes.
case dismiss(UUID)
/// **Show the files this row is about in Finder** the skip notice's per-item affordance
/// (01-storage-format.md § Malformed input: "each with Reveal in Finder").
///
/// **One control over N targets, not N controls**, and the reason is the strip's own shape: a
/// banner row is one line, three collapsible rows are all the strip shows, and a notice that grew
/// a button per skipped item would push the rows below it behind "+N more" on the very board that
/// just told the user something went wrong. So the row stays one row, the control stays one Tab
/// stop (10-accessibility.md Full Keyboard Access), and the plurality lives *inside* it the
/// strip renders a plain button for a sole target and a menu naming each path for two or more.
///
/// Never empty: `BannerRow.controls` only produces it where there is something to reveal.
case reveal([RevealTarget])
public var label: String {
switch self {
case .cancel: "Cancel"
case .dismiss: "Dismiss"
// One label for both renderings it is the button's title *and* the menu's, and it is what
// 01 calls the affordance by name.
case .reveal: "Reveal in Finder"
}
}
@@ -478,8 +533,11 @@ public final class BannerCenter {
/// Posts a loss row content that didn't arrive though nothing failed (settled 2026-07-28, see
/// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with.
public func postLoss(_ message: String) {
losses.insert(LossBanner(message: message), at: 0)
///
/// - Parameter reveals: the files this row can show in Finder, empty for every producer but the
/// skip notice (`LossBanner.reveals`).
public func postLoss(_ message: String, reveals: [RevealTarget] = []) {
losses.insert(LossBanner(message: message, reveals: reveals), at: 0)
}
/// Posts a passive notice m6's remote-change signpost and whatever joins it. Newest first,
@@ -655,6 +713,28 @@ public final class BannerCenter {
postLoss(message)
}
/// **The skip notice** (01-storage-format.md § Malformed input, ruled 2026-07-31): the decision
/// surface offered Skip on a defect the app has no honest repair for, the user consented, the
/// board opened without that item "the file stays on disk untouched, tolerated-invisible like
/// strays" and this is the row that says so.
///
/// > a skipped item loads the board without it and the opened board carries a warning-tone
/// > notice naming the skipped items, each with Reveal in Finder. Skips are per-open decisions,
/// > never persisted: the next open of a still-broken board presents the surface again the
/// > notice is the honest residue of this open, not a stored preference.
///
/// **A loss row, and the ruling names the tone**: the board on screen is not the whole board, and
/// that is exactly "content that didn't arrive though nothing failed". It must not evaporate
/// unread (the class's untimed lifecycle) and it must not rank as an error, because nothing
/// failed the user chose this.
///
/// An open that skipped nothing posts nothing: a notice about no skips is not news, and it is
/// what every ordinary open passes here.
public func postSkippedOnOpen(_ items: [RevealTarget]) {
guard let message = Self.skippedOnOpenMessage(for: items) else { return }
postLoss(message, reveals: items)
}
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
/// folders (04-interactions.md Selection, drag & drop, "Folders are refused at hover"): "a
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
@@ -1039,6 +1119,18 @@ public final class BannerCenter {
// this one *does* have a name users know from git, and "the ignore list" would be the
// app inventing a word for something already called something.
"Couldn't write this board's .gitignore"
case .mintBoardIndex:
// **Not "couldn't create the board"** the board is on screen behind the surface, with
// its lanes and its cards; what could not be written is the one file that says the
// folder is a board. It names `index.md` rather than a role because the decision surface
// the user is looking at has just named that file itself, twice: in the class's own
// sentence and on the row's Reveal.
"Couldn't create this board's index.md"
case .stampSchema:
// The repair in the user's own words the surface's choice reads "Stamp schema: 1", so
// its failure says the same thing negated. It names no file for `.mintBoardIndex`'s
// reason inverted: the file is right there and the surface just showed its path.
"Couldn't stamp this board's schema"
case let .displaceClaimedName(name):
// **The name, quoted, and what the app wanted with it** the failure's mirror of the
// success row ("Renamed '.trash' to '.trash 2' Lanework needs that name"). It names
@@ -1195,6 +1287,30 @@ public final class BannerCenter {
"Folders can't be attached — \(count) skipped"
}
/// The skip notice's line, in the relocation family's voice the act first, the subject after an
/// em dash, plurals folded, a sole item named.
///
/// - **One**: "Opened without 'todo/index.md' you chose to skip it".
/// - **Several**: "Opened without 3 items you chose to skip them".
///
/// **The plural fold is safe here in a way it is not elsewhere**, and that is the whole reason
/// the count is allowed to stand in for the names: the row carries a Reveal target per item
/// (`LossBanner.reveals`), so "which ones" is one click away rather than lost which is what
/// 01's "each with Reveal in Finder" buys. The sole case still names its path, because it fits
/// and because a one-item row that said "1 item" would be the app declining to say what it knows.
///
/// **The tail names the cause**, the migration notice's rule: without it the sentence would read
/// as something that happened *to* the user, when it is the choice they just made on the surface.
///
/// `nil` when nothing was skipped the ordinary open, and not news.
public nonisolated static func skippedOnOpenMessage(for items: [RevealTarget]) -> String? {
guard let only = items.first else { return nil }
guard items.count == 1 else {
return "Opened without \(items.count) items — you chose to skip them"
}
return "Opened without '\(only.path)' — you chose to skip it"
}
/// The loose-file relocation's line 01-storage-format.md's own example sentence, "Moved
/// 'notes.txt' into attachments 'Fix login'", generalized over the two axes it varies on.
///
+33 -5
View File
@@ -244,6 +244,25 @@ public final class BoardStore: HealHost {
/// describe the tree currently on screen.
public private(set) var loadWarnings: [LoadWarning]
/// **The skips this open consented to** the decision surface's Skip set, riding the *session*
/// (01-storage-format.md § Malformed input, ruled 2026-07-31; the posture settled here rather
/// than left to each reload).
///
/// The ruling makes skips **per-open decisions, never persisted** "the next open of a
/// still-broken board presents the surface again" and this is what "per open" means once the
/// board is on screen: every reload of this session passes the same set, so the consent the user
/// gave when the window opened holds for as long as that window does. The alternative a reload
/// that dropped the set would blank the board on the first foreign filesystem event, because
/// the defect the user tolerated is still on disk and would fail the walk again.
///
/// Nothing writes it and nothing persists it: it arrives at `init` from the open that composed
/// it, and dies with the store, which is the ruling's own "not a stored preference".
///
/// Empty on every ordinary board no surface, no skips which is what makes the reload path
/// below byte-identical to what it was for every board that opens cleanly.
@ObservationIgnored
public let skippedPaths: Set<String>
/// **The pending work the load that produced `snapshot` found** the typed defect stream
/// (`IntegrityRules.Defect`, settled 2026-07-29). Replaced with the snapshot, like
/// `loadWarnings`, so it always describes the tree currently on screen.
@@ -602,9 +621,9 @@ public final class BoardStore: HealHost {
/// with a reload behind it rather than a write into a board nothing is watching yet. A store
/// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload
/// thereafter.
public convenience init(rootURL: URL) throws(BoardLoadFailure) {
let result = try BoardLoader.load(boardRoot: rootURL)
self.init(rootURL: rootURL, loaded: result)
public convenience init(rootURL: URL, skipping: Set<String> = []) throws(BoardLoadFailure) {
let result = try BoardLoader.load(boardRoot: rootURL, skipping: skipping)
self.init(rootURL: rootURL, loaded: result, skipping: skipping)
}
/// The same board, from a walk that already happened somewhere else.
@@ -618,11 +637,16 @@ public final class BoardStore: HealHost {
/// `rootURL` is passed rather than read off `result.model` for the reason the property's own doc
/// comment gives the store's root follows an absorbed rename ahead of the snapshot that will
/// carry it.
public init(rootURL: URL, loaded result: LoadResult) {
///
/// - Parameter skipping: the skip set the walk was run with, retained for this session's reloads
/// (`skippedPaths`). Defaulted to none, which is every board that opened without a decision
/// surface.
public init(rootURL: URL, loaded result: LoadResult, skipping: Set<String> = []) {
self.rootURL = rootURL
self.snapshot = result.model
self.loadWarnings = result.warnings
self.defects = result.defects
self.skippedPaths = skipping
self.reloadFailure = nil
self.readOnlyLock = nil
self.transient = TransientBoardState()
@@ -693,6 +717,9 @@ public final class BoardStore: HealHost {
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
// break a tie for. `nil` everywhere the app manages no git.
let historyRanker = makeIdentityHistoryRanker?()
// **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's
// decision stands for the session, so a reload sees the board the user chose to open.
let skipping = skippedPaths
reloadInFlight = true
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
@@ -701,7 +728,8 @@ public final class BoardStore: HealHost {
// and the loader's typed failure is lost on the way into `Result`.
let outcome: Result<LoadResult, BoardLoadFailure>
do throws(BoardLoadFailure) {
outcome = .success(try BoardLoader.load(boardRoot: root, historyRanker: historyRanker))
outcome = .success(try BoardLoader.load(
boardRoot: root, skipping: skipping, historyRanker: historyRanker))
} catch {
outcome = .failure(error)
}
+26 -6
View File
@@ -197,21 +197,37 @@ public final class BoardStoreRegistry {
/// returns `nil` before constructing anything, so there is no store, no watcher, no entry and no
/// reference and the caller's security-scoped access is its own to release.
///
/// - Parameter skipping: **the decision surface's consented skips** (01-storage-format.md
/// § Malformed input, ruled 2026-07-31), passed to the walk and then *retained by the store*
/// so every reload of the resulting session runs with the same set (`BoardStore.skippedPaths`).
/// Empty the default is every board that opens without a surface.
///
/// - Returns: the board's store, or `nil` if this acquire was cancelled before its walk landed.
/// `nil` is not a failure: nothing went wrong and nothing was opened.
public func acquireOffMain(_ rootURL: URL) async throws(BoardLoadFailure) -> BoardStore? {
public func acquireOffMain(
_ rootURL: URL,
skipping: Set<String> = []
) async throws(BoardLoadFailure) -> BoardStore? {
if let store = referenceExistingBoard(at: rootURL) { return store }
// `nil` for a root that does not exist or whose volume will not answer there is nothing to
// coalesce on, so such an open walks alone and the loader produces the honest error for it.
let identity = FileIdentity(of: rootURL)
//
// **A skip-carrying acquire also walks alone**, deliberately: the single flight exists so
// concurrent opens of one board share a walk, and two walks are only shareable when they
// would produce the same answer. A skip set changes what the walk *finds*, so joining one
// would hand a window a board someone else's consent composed and a Repair-and-Open's
// re-walk could be answered by the very walk that failed. The condition is exactly "an
// ordinary open", which is every open the coalescing was built for (restoration, a Finder
// open racing it, a card window arriving first) and none of the surface's.
let identity = skipping.isEmpty ? FileIdentity(of: rootURL) : nil
let walk: Task<Result<LoadResult, BoardLoadFailure>, Never>
if let identity, let joined = walksInFlight[identity] {
Self.logger.debug("acquire: joining the walk already running for this board")
walk = joined
} else {
walk = Self.walk(rootURL)
walk = Self.walk(rootURL, skipping: skipping)
if let identity { walksInFlight[identity] = walk }
}
@@ -238,7 +254,8 @@ public final class BoardStoreRegistry {
case let .failure(failure):
throw failure
case let .success(result):
return try adopt(BoardStore(rootURL: rootURL, loaded: result), rootURL: rootURL)
return try adopt(
BoardStore(rootURL: rootURL, loaded: result, skipping: skipping), rootURL: rootURL)
}
}
@@ -246,10 +263,13 @@ public final class BoardStoreRegistry {
/// `BoardStore.startReload`'s reason exactly: a task created inside a `@MainActor` method
/// inherits that isolation and would run the walk on the main actor, which is the whole thing
/// this is avoiding.
private static func walk(_ rootURL: URL) -> Task<Result<LoadResult, BoardLoadFailure>, Never> {
private static func walk(
_ rootURL: URL,
skipping: Set<String>
) -> Task<Result<LoadResult, BoardLoadFailure>, Never> {
Task.detached(priority: .userInitiated) {
do throws(BoardLoadFailure) {
return .success(try BoardLoader.load(boardRoot: rootURL))
return .success(try BoardLoader.load(boardRoot: rootURL, skipping: skipping))
} catch {
return .failure(error)
}
+48
View File
@@ -246,6 +246,54 @@ public final class EchoLedger: Sendable {
}
}
/// **Marks everything this ledger holds as a heal** the whole-ledger form of `markHeal(at:)`,
/// for a ledger whose *every* receipt is a heal by construction.
///
/// Its one caller is the decision surface's repair bracket (01-storage-format.md § Malformed
/// input: "On Pro boards the repairs drop heal-marked receipts and commit separately as one
/// repair commit"). Repairs run **before** the board has a store there is no `BoardStore` yet,
/// so no `performWrite` to bind so the repair binds a ledger of its own for the duration of the
/// bracket. Everything that lands in it is a repair, which is exactly the condition that makes a
/// blanket mark honest here and would make it a lie on a session ledger.
///
/// Path-by-path marking would need the repair runner to enumerate the files each `BoardWriter`
/// call happened to touch `createBoard` writes `index.md` *and* seeds `.gitignore` which is
/// bookkeeping the Writer exists to keep call sites out of.
public func markAllAsHeal() {
receipts.withLock { store in
for path in store.keys {
store[path]?.isHeal = true
}
}
}
/// **Takes over another ledger's receipts, attributes and all** the repair bracket's ledger
/// handed to the board's own once the board finally has one.
///
/// The decision surface repairs a board that has no store, then re-walks it; the walk succeeds,
/// the store is built, and only *then* does a session ledger exist. Without this the repair's
/// receipts would die with the temporary ledger and Pro's committer would author the app's own
/// repair as `Lanework External` the one misattribution the whole mechanism exists to prevent.
///
/// **Safe because a receipt vouches against disk, not against a clock** (`Receipt.isSatisfied`):
/// the repaired files are on disk exactly as the repair left them, and the re-walk that just
/// succeeded read those very bytes. An adopted receipt is therefore satisfiable the moment it
/// arrives, which is the same standing a receipt dropped inside a write bracket has.
///
/// Plain overwrite, the supersession rule: the adopting ledger is brand new in the only case that
/// calls this, and a receipt it already held for the same path would be the newer of the two
/// which is the one case the ledger's own `recordWrite` also resolves by keeping what it has been
/// told last. Nothing is removed from `other`; it is discarded whole by its caller.
public func adopt(_ other: EchoLedger) {
let entries = other.receipts.withLock { $0 }
guard !entries.isEmpty else { return }
receipts.withLock { store in
for (path, entry) in entries {
store[path] = entry
}
}
}
private static func forget(_ store: inout [String: Entry], under path: String) {
let prefix = path + "/"
for key in Array(store.keys) where key.hasPrefix(prefix) {
+47 -2
View File
@@ -314,8 +314,17 @@ public enum BoardWriter: Sendable {
/// `index.md`, after it, so the file that makes a folder a board is written first and a failure
/// to seed can never leave a half-made board. Seeding is `seedGitignoreIfAbsent`'s, so a
/// creation into a folder that somehow already carries one leaves it alone.
public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) {
let operation = WriteOperation.createBoard
/// - Parameter operation: what the *caller* was doing, for the banner's sake `.createBoard` for
/// every gesture that makes a board, and `.mintBoardIndex` for the decision surface's repair of
/// a board folder that has everything except the file that says it is one
/// (01-storage-format.md § Malformed input). The mechanics are identical, which is exactly why
/// the repair reuses this method rather than growing a second one; only the sentence a failure
/// would produce differs, and that sentence is the vocabulary's whole job.
public static func createBoard(
at rootURL: URL,
title: String?,
operation: WriteOperation = .createBoard
) throws(BoardWriteError) {
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
guard !FileManager.default.fileExists(atPath: indexURL.path) else {
@@ -2952,6 +2961,34 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// It never describes an *edit*: the app writes this file only when nothing holds the name.
case seedGitignore
/// **The decision surface's minted board index** (01-storage-format.md § Malformed input, settled
/// 2026-07-31): "*Board root without `index.md`* minted repair, the default: create a board
/// index (folder-name title, `schema: 1`)".
///
/// Its own case rather than a fold into `.createBoard`, on the vocabulary's standing reasoning:
/// nothing is being *created* the board is right there, with its lanes and its cards, and the
/// user opened it so a banner saying the app "couldn't create the board" would name a gesture
/// nobody made and a thing that already exists. What is missing is the one file that says the
/// folder is a board, and that is what this names.
///
/// **No payload**, for `.seedGitignore`'s reason: there is one such file per board, its name is
/// fixed, and the board it belongs to has no title yet reading one is precisely what the
/// missing file prevents.
case mintBoardIndex
/// **The decision surface's `schema: 1` stamp** (01-storage-format.md § Malformed input): "*Board
/// root missing `schema`* stamp `schema: 1`, the default: reliable exactly because the walk just
/// validated the file against schema 1."
///
/// Its own case beside `.mintBoardIndex` rather than a fold into it the no-folding rule the
/// whole vocabulary is built on because the two repairs have different outcomes a user could
/// care about: one writes a file that was not there, the other adds a key to a file that was. A
/// banner saying the app could not create this board's `index.md` when the file is sitting in
/// Finder would be actively wrong.
///
/// **No payload**, for `.mintBoardIndex`'s reason exactly.
case stampSchema
/// A wrong-kinded node being moved off a board-root name the app claims a file or symlink
/// squatting `.trash` (01-storage-format.md § Fractal layout Rules, ruled 2026-07-29: "moved
/// aside by a scheduled heal via the Finder-style rename ladder").
@@ -3074,8 +3111,13 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
// The comment family is identity for `.repairDuplicateID`'s reason, doubled: a comment's
// `index.md` carries no `title` to enrich from, and the title these five hold is the *card's*,
// filled in by the caller from the window the gesture came from.
// The two decision-surface repairs join this list for `.createBoard`'s and
// `.seedGitignore`'s reasons at once: neither carries a title slot, and the board they
// repair has no readable title to enrich from a root with no `index.md` has no document
// at all, and one with no `schema` is the file the walk just refused.
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
.mintBoardIndex, .stampSchema,
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
self
@@ -3139,6 +3181,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
.mintBoardIndex, .stampSchema,
.displaceClaimedName,
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
@@ -3176,6 +3219,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case .agentGuide: "update the agent guide"
case .seedGitignore: "seed the board's .gitignore"
case .mintBoardIndex: "create this board's index.md"
case .stampSchema: "stamp this board's schema"
case let .displaceClaimedName(name): "move a stray '\(name)' aside"
case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title)
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
+28
View File
@@ -43,6 +43,34 @@ enum AccessibilityPhrases {
/// would leave that window silent.
static let boardLoading = "Loading board"
/// **What the decision surface is called** (01-storage-format.md § Malformed input, settled
/// 2026-07-31; `BoardDecisionSurface`) its heading *and* the group label a VoiceOver user hears
/// on entering it, which are one string here for the banner row's reason exactly: the sentence
/// heard and the sentence read are one, or they are two descriptions of one thing waiting to
/// disagree.
///
/// It says the board did not open and stops there. What is wrong is the sections' to say the
/// surface exists precisely because there is usually more than one answer to that.
static let decisionSurfaceLabel = "Lanework couldn't open this board"
/// The surface's own subtitle: how much is wrong, and what to do about it. The count is the
/// number of affected *files*, which is what the rows below it are.
static func decisionSurfaceSummary(defects count: Int) -> String {
let subject = count == 1 ? "One file needs" : "\(count) files need"
return "\(subject) a decision before this board can open."
}
/// One affected file, as one utterance: **path then reason**, in that order, because the path is
/// what identifies the row and the reason is what the user is deciding about
/// (01: "lists the affected files (path + specifics )").
///
/// The reason is the loader's own sentence, unrewritten the same specifics the row shows for
/// `BannerCenter.causePhrase`'s reason: fail-fast's diagnostics are specific in a way a
/// re-phrasing would not be, and the alternative to showing one is a shrug.
static func decisionRowLabel(path: String, reason: String) -> String {
"\(path): \(reason)"
}
/// "3 cards", "1 card" the app's **one** plural folding for a card count, borrowed from
/// `TrashModel.phrase` rather than restated so a lane's spoken count and the trash column's
/// cannot drift apart.
+29
View File
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI
/// The banner strip: one window's standing conditions, unread failures, and work in flight, as a
@@ -218,6 +219,26 @@ private struct BannerRowView: View {
Button(control.label, action: cancel)
.buttonStyle(.link)
.font(.callout)
case let .reveal(targets):
// **One control, two renderings** (`BannerRowControl.reveal`): a sole skipped item gets a
// plain button, several get a menu naming each path. The row stays one line and one Tab
// stop either way, which is what keeps the strip's three-row budget intact on the very
// board that just reported something wrong.
if targets.count == 1, let only = targets.first {
Button(control.label) { Self.reveal(only) }
.buttonStyle(.link)
.font(.callout)
} else {
Menu(control.label) {
ForEach(targets) { target in
Button(target.path) { Self.reveal(target) }
}
}
.menuStyle(.borderlessButton)
.fixedSize()
.font(.callout)
.accessibilityLabel(control.label)
}
case let .dismiss(id):
Button {
onDismiss(id)
@@ -230,6 +251,14 @@ private struct BannerRowView: View {
.help(control.label)
}
}
/// Finder, with the file selected `activateFileViewerSelecting` rather than `open`, so a
/// skipped `index.md` is shown *in its folder* rather than handed to whatever app claims `.md`.
/// The user's next act is looking at where the file sits; opening it is the surface's other
/// affordance, not this one.
private static func reveal(_ target: RevealTarget) {
NSWorkspace.shared.activateFileViewerSelecting([target.url])
}
}
// MARK: - Tone rendering
+831
View File
@@ -0,0 +1,831 @@
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<String> {
var paths: Set<String> = []
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` (`"<lane>/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<BoardDefectChoice>.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<BoardDefectChoice?> {
Binding(
get: { section.classChoice },
set: { choice in
guard let choice else { return }
model.choose(choice, inClass: section.defectClass)
}
)
}
private func rowChoiceBinding(_ row: BoardDefectRow) -> Binding<BoardDefectChoice?> {
Binding(
get: { row.choice },
set: { choice in
guard let choice else { return }
model.choose(choice, forPath: row.id)
}
)
}
}