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
+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.
///