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
+98
View File
@@ -0,0 +1,98 @@
import SwiftUI
/// The colour and icon vocabulary `icon`, `iconColor`, and `background` are written in.
///
/// **Mirrors `Kanban/UI/Palette.swift` and `SymbolPickerCatalog`, not shared with them.**
/// `project.yml` excludes `Kanban/UI` from the phone target outright it is AppKit-only so this
/// is an independent copy of the same namehex tables and the same curated symbol grid, kept as
/// pure data (no `NSColor`, no `AppKit`). The two pickers are free to diverge; nothing here reads
/// the Mac's file or vice versa.
enum CardPalette {
struct Swatch: Identifiable, Sendable {
/// Kebab-case, exactly as written to frontmatter (`iconColor`/`background`'s `color`
/// subkey read this way see `FrontmatterFields.swift`).
let name: String
let hex: String
var id: String { name }
}
/// `iconColor`'s twelve wells.
static let foregrounds: [Swatch] = [
Swatch(name: "obsidian", hex: "#000000"),
Swatch(name: "aluminum", hex: "#9B9B9B"),
Swatch(name: "soapstone", hex: "#D5D5D5"),
Swatch(name: "chalk", hex: "#FFFFFF"),
Swatch(name: "carnation", hex: "#FF576C"),
Swatch(name: "rich-grapefruit", hex: "#FF864C"),
Swatch(name: "smokey-tangerine", hex: "#E5A334"),
Swatch(name: "fern", hex: "#50B23D"),
Swatch(name: "light-teal", hex: "#00B7B7"),
Swatch(name: "deep-sky-blue", hex: "#0084E5"),
Swatch(name: "pale-violet", hex: "#8C59C5"),
Swatch(name: "deep-cool-granite", hex: "#597199"),
]
/// `background`'s twelve wells written into the mapping's `color` subkey
/// (`FrontmatterDocument.setStyleValue`).
static let backgrounds: [Swatch] = [
Swatch(name: "obsidian", hex: "#000000"),
Swatch(name: "shale", hex: "#5B5B5B"),
Swatch(name: "aluminum", hex: "#9B9B9B"),
Swatch(name: "chalk", hex: "#FFFFFF"),
Swatch(name: "light-cayenne", hex: "#B6071E"),
Swatch(name: "light-mocha", hex: "#B73C14"),
Swatch(name: "smokey-mocha", hex: "#674611"),
Swatch(name: "smokey-fern", hex: "#145312"),
Swatch(name: "dark-teal", hex: "#005152"),
Swatch(name: "smokey-ocean", hex: "#003168"),
Swatch(name: "smokey-rich-eggplant", hex: "#290659"),
Swatch(name: "intense-cool-shale", hex: "#1F2E45"),
]
/// A general "boards and projects" grid `SymbolPickerCatalog.defaultSet`, mirrored for the
/// same reason as the colour tables above.
static let icons: [String] = [
"star", "flag", "heart", "bolt", "flame", "leaf", "drop", "sun.max", "moon", "sparkles",
"tag", "bookmark", "pin", "bell", "paperplane", "tray", "folder", "archivebox", "doc.text",
"list.bullet", "checklist", "calendar", "clock", "hammer", "wrench.and.screwdriver",
"paintbrush", "lightbulb", "brain", "book", "graduationcap", "briefcase", "cart", "house",
"airplane", "gamecontroller", "globe",
]
/// Resolves a stored palette name to a colour, for rendering a well or a row accent.
///
/// **Lenient, like the field itself** (`Kanban/UI/Palette.swift`'s own framing): an
/// unrecognized name a custom hex the field allows but this picker does not offer, a typo, a
/// name from some future palette answers `nil`, and every caller here falls back to its own
/// default rather than guessing at a colour. Nothing on disk is affected either way.
static func color(named name: String, in swatches: [Swatch]) -> Color? {
guard let swatch = swatches.first(where: { $0.name == name }) else { return nil }
return Color(paletteHex: swatch.hex)
}
}
private extension Color {
/// `#RRGGBB` or `#RRGGBBAA` the two forms `CardPalette.Swatch.hex` uses. Malformed input
/// answers `nil` rather than a guessed colour, matching `CardPalette.color(named:in:)`'s own
/// contract.
init?(paletteHex hex: String) {
var value = hex
if value.hasPrefix("#") { value.removeFirst() }
guard value.count == 6 || value.count == 8, let intValue = UInt64(value, radix: 16) else { return nil }
let r, g, b, a: Double
if value.count == 8 {
r = Double((intValue >> 24) & 0xFF) / 255
g = Double((intValue >> 16) & 0xFF) / 255
b = Double((intValue >> 8) & 0xFF) / 255
a = Double(intValue & 0xFF) / 255
} else {
r = Double((intValue >> 16) & 0xFF) / 255
g = Double((intValue >> 8) & 0xFF) / 255
b = Double(intValue & 0xFF) / 255
a = 1
}
self.init(red: r, green: g, blue: b, opacity: a)
}
}