The phone joins the format — KanbanMobile MVP: shared storage verbatim over an iCloud container

A second product, not a second edition: dev.rzen.indie.KanbanMobile (iOS 26,
iPhone-only) compiles Kanban/Storage as source files, so a format change that
breaks the phone breaks this build the day it's made. Boards live in
iCloud.dev.rzen.indie.Kanban — named after the Mac bundle id so the Mac app can
adopt the container later without a migration; until then the folder is
"Lanework" in iCloud Drive and the Mac opens boards there through the open
panel.

EchoLedger grows #if os(macOS) gates around its three consumer surfaces
(verdicts/BoardDiff, harvest/HarvestedReceipt, comment retirement/CommentPath)
— the recording side BoardWriter stamps compiles on every platform, and the
gates are the seam a future phone verdict surface lands behind. AgentGuide
stays Mac-only.

The phone's watcher is NSMetadataQuery: BoardIndexStore (one query, package
UTI export makes a .kanban directory one item, equality-gated rescans,
download kicks per pass), BoardSession (materialization sweep before every
fail-fast walk, NSFileCoordinator brackets, perform{} = coordinated write then
awaited reload, ParseMemo threaded), CloudHome (off-main container resolution,
LANEWORK_LOCAL_ROOT DEBUG override for simulator work without an account).

Screens: Boards -> lanes -> cards -> card editor, value-routed by ItemID with
every screen re-reading the live snapshot; leading swipe moves a card via
confirmationDialog, trailing swipe sends it to .trash/; the editor commits
title through the Mac's canonical rename path and body through writeBody, with
drafts that survive reloads; attributes are the three typed style fields
(icon, iconColor, background) — labels is a reserved unknown-field key and
deliberately has no editor. Settings carries IndieBackup (backup root = the
container's Documents, restore rebuild = an index rescan, controller
constructed only once the home resolves).

Arbiter: KanbanMobile green for iOS Simulator, Kanban green for macOS, 3006
unit tests / 517 suites passed (PointerLatencyTests excluded — mid-rework
uncommitted in a parallel session).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-07 22:49:36 -04:00
parent c67c1037f1
commit eac1c02a7d
22 changed files with 2599 additions and 1 deletions
+113
View File
@@ -0,0 +1,113 @@
import SwiftUI
/// The root of the boards navigation stack: board list lanes cards card detail
/// (`BoardRoute`'s destinations).
///
/// Renders all three of `BoardIndexStore.Phase` the property the placeholder this replaces
/// existed to prove, and the split this screen keeps.
struct BoardsTabView: View {
@Environment(BoardIndexStore.self) private var index
@State private var isPresentingNewBoard = false
var body: some View {
NavigationStack {
content
.navigationTitle("Boards")
.toolbar {
if case .ready = index.phase {
Button("New Board", systemImage: "plus") {
isPresentingNewBoard = true
}
}
}
.navigationDestination(for: BoardRoute.self) { route in
switch route {
case let .lanes(boardRoot):
BoardScreen(boardRoot: boardRoot)
case let .cards(boardRoot, laneID):
LaneScreen(boardRoot: boardRoot, laneID: laneID)
case let .card(boardRoot, laneID, cardID):
CardDetailScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID)
}
}
}
.task { index.start() }
.titlePromptAlert("New Board", isPresented: $isPresentingNewBoard, placeholder: "Board Title") { title in
Task { await index.createBoard(titled: title) }
}
}
@ViewBuilder
private var content: some View {
switch index.phase {
case .idle, .loading:
ProgressView("Looking for boards")
case let .unavailable(reason):
// The hard-iCloud-requirement wall. `reason` distinguishes "sign in" from "the container
// did not resolve", which are different asks of the user.
ContentUnavailableView {
Label("iCloud Required", systemImage: "icloud.slash")
} description: {
Text(reason == .noAccount
? "Sign into iCloud in Settings to use Lanework."
: "Lanework can't reach its iCloud Drive folder right now.")
} actions: {
Button("Try Again") { index.start() }
}
case .ready where index.boards.isEmpty:
ContentUnavailableView(
"No Boards Yet",
systemImage: "rectangle.stack",
description: Text("Boards in your iCloud Drive will appear here.")
)
case .ready:
List(index.boards) { board in
NavigationLink(value: BoardRoute.lanes(boardRoot: board.rootURL)) {
BoardSummaryRow(board: board)
}
}
.refreshable { index.refresh() }
}
}
}
/// One board row: title, lane/card counts, modified date, and while the package is not fully
/// current a download-state subtitle in place of the counts a shallow scan cannot yet answer.
private struct BoardSummaryRow: View {
let board: BoardSummary
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(board.title)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var subtitle: String {
switch board.download {
case .notDownloaded:
"Waiting to download"
case let .downloading(fraction):
fraction.map { "Downloading \(Int($0 * 100))%" } ?? "Downloading"
case .current, .unknown:
countsAndModified
}
}
private var countsAndModified: String {
let counts: String
if let lanes = board.laneCount, let cards = board.cardCount {
counts = "\(lanes) lane\(lanes == 1 ? "" : "s") · \(cards) card\(cards == 1 ? "" : "s")"
} else {
counts = ""
}
guard let modified = board.modified else { return counts }
return "\(counts) · \(modified.formatted(.relative(presentation: .named)))"
}
}