The phone's rows learn their faces and two more orders — icons everywhere, lanes and cards sortable

Boards, lanes, and cards all show their frontmatter icon leading the
row, tinted through the palette with a secondary fallback; the board
scanner now reads icon and iconColor from the same one-file parse as
the title. Lane and card lists gain the segmented Manual/Name/Recent
control — display-only sorting layered over the rank order, never
rewriting what's on disk.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 12:34:10 -04:00
parent 445d035a83
commit e8791b239d
7 changed files with 209 additions and 44 deletions
+4
View File
@@ -1,5 +1,9 @@
**August 2026** **August 2026**
Sort lanes and cards by your own arrangement, name, or most recent change.
Boards, lanes, and cards now show their icon and color in the lists.
Sort your board list by name or by most recently changed with the new control above the list. Sort your board list by name or by most recently changed with the new control above the list.
Version 1.0: Lanework comes to iPhone — browse your boards from iCloud Drive, move cards between lanes, and edit them on the go. Version 1.0: Lanework comes to iPhone — browse your boards from iCloud Drive, move cards between lanes, and edit them on the go.
+28 -9
View File
@@ -17,6 +17,14 @@ struct BoardSummary: Identifiable, Sendable, Equatable {
/// no `title` key is a normal board rather than an untitled one. /// no `title` key is a normal board rather than an untitled one.
let title: String let title: String
/// The board `index.md`'s `icon:`, verbatim an SF Symbol name, or `nil` when unset or the file
/// couldn't be read. Unlike `title`, there is no fallback: a board with no icon shows none.
let icon: String?
/// The board `index.md`'s `iconColor:`, verbatim a `CardPalette` swatch name, or `nil` on the
/// same terms as `icon`.
let iconColor: String?
/// Lanes the loader would show. `nil` where the package is not materialized enough to count /// Lanes the loader would show. `nil` where the package is not materialized enough to count
/// see `BoardSummaryScanner` for why a number is withheld rather than guessed at zero. /// see `BoardSummaryScanner` for why a number is withheld rather than guessed at zero.
let laneCount: Int? let laneCount: Int?
@@ -172,6 +180,8 @@ enum BoardSummaryScanner {
return BoardSummary( return BoardSummary(
rootURL: entry.rootURL, rootURL: entry.rootURL,
title: fallbackTitle, title: fallbackTitle,
icon: nil,
iconColor: nil,
laneCount: nil, laneCount: nil,
cardCount: nil, cardCount: nil,
modified: entry.modified, modified: entry.modified,
@@ -181,7 +191,8 @@ enum BoardSummaryScanner {
} }
let indexURL = entry.rootURL.appendingPathComponent(IntegrityRules.indexFileName) let indexURL = entry.rootURL.appendingPathComponent(IntegrityRules.indexFileName)
let title = readTitle(at: indexURL) ?? fallbackTitle let fields = readIndexFields(at: indexURL)
let title = fields.title ?? fallbackTitle
// An unreadable board `index.md` means this is not a board the loader would open a package // An unreadable board `index.md` means this is not a board the loader would open a package
// still arriving, or one whose root file is genuinely broken. Either way a count would be a // still arriving, or one whose root file is genuinely broken. Either way a count would be a
@@ -190,6 +201,8 @@ enum BoardSummaryScanner {
return BoardSummary( return BoardSummary(
rootURL: entry.rootURL, rootURL: entry.rootURL,
title: title, title: title,
icon: fields.icon,
iconColor: fields.iconColor,
laneCount: nil, laneCount: nil,
cardCount: nil, cardCount: nil,
modified: entry.modified, modified: entry.modified,
@@ -208,6 +221,8 @@ enum BoardSummaryScanner {
return BoardSummary( return BoardSummary(
rootURL: entry.rootURL, rootURL: entry.rootURL,
title: title, title: title,
icon: fields.icon,
iconColor: fields.iconColor,
laneCount: lanes, laneCount: lanes,
cardCount: cards, cardCount: cards,
modified: entry.modified, modified: entry.modified,
@@ -216,18 +231,22 @@ enum BoardSummaryScanner {
) )
} }
/// The board title as written, or `nil` where the file is absent, is not UTF-8, has no /// The board `index.md`'s `title:`/`icon:`/`iconColor:`, read together since they come from the
/// frontmatter, or carries no usable `title:` every one of which is the folder-name fallback. /// same one-file parse. `title` is `nil` where the file is absent, is not UTF-8, has no
private nonisolated static func readTitle(at indexURL: URL) -> String? { /// frontmatter, or carries no usable `title:` every one of which is the folder-name fallback in
/// `scan`. `icon`/`iconColor` are `nil` on the same unreadable-file terms, and also whenever the
/// document simply doesn't set them there is no fallback for either.
private nonisolated static func readIndexFields(
at indexURL: URL
) -> (title: String?, icon: String?, iconColor: String?) {
guard let data = try? Data(contentsOf: indexURL), guard let data = try? Data(contentsOf: indexURL),
let text = String(data: data, encoding: .utf8), let text = String(data: data, encoding: .utf8),
let document = try? FrontmatterDocument.parse(text), let document = try? FrontmatterDocument.parse(text)
let title = document.title.value,
!title.isEmpty
else { else {
return nil return (nil, nil, nil)
} }
return title let title = document.title.value
return (title?.isEmpty == false ? title : nil, document.icon.value, document.iconColor.value)
} }
/// Direct subfolders that are lanes or cards by the loader's own two gates, reached through the /// Direct subfolders that are lanes or cards by the loader's own two gates, reached through the
+45 -10
View File
@@ -5,15 +5,30 @@ import SwiftUI
/// Holds only `boardRoot`, never a `BoardSummary`/`BoardModel` value: the session and its /// Holds only `boardRoot`, never a `BoardSummary`/`BoardModel` value: the session and its
/// snapshot are pulled from the environment's index store fresh on every body evaluation, so a /// snapshot are pulled from the environment's index store fresh on every body evaluation, so a
/// write from anywhere in the stack (a lane created, a card moved) reaches this screen the moment /// write from anywhere in the stack (a lane created, a card moved) reaches this screen the moment
/// the session's reload lands. /// the session's reload lands. A segmented control above the lane rows lets the list be sorted by
/// name or by most recent change, on top of the board's own manual arrangement, remembered across
/// launches.
struct BoardScreen: View { struct BoardScreen: View {
let boardRoot: URL let boardRoot: URL
@Environment(BoardIndexStore.self) private var index @Environment(BoardIndexStore.self) private var index
@State private var isPresentingNewLane = false @State private var isPresentingNewLane = false
/// Persisted as its raw value, not the enum itself same reasoning as `BoardsTabView`'s
/// `sortOrderRaw`.
@AppStorage("laneListSortOrder") private var sortOrderRaw = ItemSortOrder.manual.rawValue
private var session: BoardSession { index.session(forBoardAt: boardRoot) } private var session: BoardSession { index.session(forBoardAt: boardRoot) }
/// The stored raw value as the enum, defaulting to `.manual` on anything the store didn't
/// write itself an unset key or a stale raw value from a build that no longer has this case.
private var sortOrder: Binding<ItemSortOrder> {
Binding(
get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .manual },
set: { sortOrderRaw = $0.rawValue }
)
}
var body: some View { var body: some View {
content content
.navigationTitle(title) .navigationTitle(title)
@@ -51,6 +66,13 @@ struct BoardScreen: View {
session.snapshot?.title.value ?? boardRoot.deletingPathExtension().lastPathComponent session.snapshot?.title.value ?? boardRoot.deletingPathExtension().lastPathComponent
} }
/// The row's display title, "Untitled Lane" fallback included fed to `ItemSortOrder.sorted`
/// so `.name` sorts on exactly what the row shows.
private func displayTitle(of lane: Lane) -> String {
guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" }
return title
}
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
switch session.phase { switch session.phase {
@@ -101,9 +123,19 @@ struct BoardScreen: View {
description: Text("Add a lane to start organizing cards.") description: Text("Add a lane to start organizing cards.")
) )
} else { } else {
// Already in display order (`BoardModel.lanes`'s own contract) the loader's Picker("Sort", selection: sortOrder) {
// `Ranks.sortedForDisplay` is trusted rather than re-sorted here. Text("Manual").tag(ItemSortOrder.manual)
ForEach(snapshot.lanes) { lane in Text("Name").tag(ItemSortOrder.name)
Text("Recent").tag(ItemSortOrder.recent)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
// The trusted order is `.manual`'s base `BoardModel.lanes` is already in display
// order (the loader's `Ranks.sortedForDisplay`) and `.name`/`.recent` layer a
// display-only re-sort on top, never touching that base or what's on disk.
ForEach(sortOrder.wrappedValue.sorted(snapshot.lanes, title: displayTitle(of:), modified: { $0.modified.value })) { lane in
NavigationLink(value: BoardRoute.cards(boardRoot: boardRoot, laneID: lane.id)) { NavigationLink(value: BoardRoute.cards(boardRoot: boardRoot, laneID: lane.id)) {
LaneSummaryRow(lane: lane) LaneSummaryRow(lane: lane)
} }
@@ -114,16 +146,19 @@ struct BoardScreen: View {
} }
} }
/// One lane row: title, and its card count. /// One lane row: its icon when it has one, title, and its card count.
private struct LaneSummaryRow: View { private struct LaneSummaryRow: View {
let lane: Lane let lane: Lane
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 2) { HStack {
Text(displayTitle) ItemIconView(icon: lane.icon.value, iconColor: lane.iconColor.value)
Text("\(lane.cards.count) card\(lane.cards.count == 1 ? "" : "s")") VStack(alignment: .leading, spacing: 2) {
.font(.caption) Text(displayTitle)
.foregroundStyle(.secondary) Text("\(lane.cards.count) card\(lane.cards.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
} }
} }
+14 -11
View File
@@ -150,9 +150,9 @@ struct BoardsTabView: View {
} }
} }
/// One board row: title, lane/card counts, modified date, a marker for a board that is not in iCloud, /// One board row: its icon when it has one, title, lane/card counts, modified date, a marker for a
/// and while the package is not fully current a download-state subtitle in place of the counts a /// board that is not in iCloud, and while the package is not fully current a download-state
/// shallow scan cannot yet answer. /// subtitle in place of the counts a shallow scan cannot yet answer.
/// ///
/// Only the local side is marked. iCloud is where a board is expected to be, so saying so on every /// Only the local side is marked. iCloud is where a board is expected to be, so saying so on every
/// row would be noise; "Local" is the exception, and the exception is what a marker is for. /// row would be noise; "Local" is the exception, and the exception is what a marker is for.
@@ -160,16 +160,19 @@ private struct BoardSummaryRow: View {
let board: BoardSummary let board: BoardSummary
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 2) { HStack {
Text(board.title) ItemIconView(icon: board.icon, iconColor: board.iconColor)
HStack(spacing: 6) { VStack(alignment: .leading, spacing: 2) {
if board.location == .local { Text(board.title)
Label("Local", systemImage: "iphone") HStack(spacing: 6) {
if board.location == .local {
Label("Local", systemImage: "iphone")
}
Text(subtitle)
} }
Text(subtitle) .font(.caption)
.foregroundStyle(.secondary)
} }
.font(.caption)
.foregroundStyle(.secondary)
} }
} }
+39 -14
View File
@@ -5,7 +5,9 @@ import SwiftUI
/// Holds `boardRoot` and `laneID`, never a `Lane` value: the lane is re-read from /// Holds `boardRoot` and `laneID`, never a `Lane` value: the lane is re-read from
/// `session.snapshot` on every body evaluation, so a write this screen makes or one relayed /// `session.snapshot` on every body evaluation, so a write this screen makes or one relayed
/// from elsewhere through the metadata query reaches the list the moment the session's reload /// from elsewhere through the metadata query reaches the list the moment the session's reload
/// lands, and a lane deleted on another device is noticed rather than shown stale. /// lands, and a lane deleted on another device is noticed rather than shown stale. A segmented
/// control above the card rows lets the list be sorted by name or by most recent change, on top
/// of the lane's own manual arrangement, remembered across launches.
struct LaneScreen: View { struct LaneScreen: View {
let boardRoot: URL let boardRoot: URL
let laneID: ItemID let laneID: ItemID
@@ -16,8 +18,21 @@ struct LaneScreen: View {
@State private var isPresentingNewCard = false @State private var isPresentingNewCard = false
@State private var cardPendingMove: ItemID? @State private var cardPendingMove: ItemID?
/// Persisted as its raw value, not the enum itself same reasoning as `BoardsTabView`'s
/// `sortOrderRaw`.
@AppStorage("cardListSortOrder") private var sortOrderRaw = ItemSortOrder.manual.rawValue
private var session: BoardSession { index.session(forBoardAt: boardRoot) } private var session: BoardSession { index.session(forBoardAt: boardRoot) }
/// The stored raw value as the enum, defaulting to `.manual` on anything the store didn't
/// write itself an unset key or a stale raw value from a build that no longer has this case.
private var sortOrder: Binding<ItemSortOrder> {
Binding(
get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .manual },
set: { sortOrderRaw = $0.rawValue }
)
}
private var lane: Lane? { private var lane: Lane? {
session.snapshot?.lanes.first { $0.id == laneID } session.snapshot?.lanes.first { $0.id == laneID }
} }
@@ -66,6 +81,13 @@ struct LaneScreen: View {
return title return title
} }
/// The row's display title, "Untitled Card" fallback included fed to `ItemSortOrder.sorted`
/// so `.name` sorts on exactly what the row shows.
private func displayTitle(of card: Card) -> String {
guard let title = card.title.value, !title.isEmpty else { return "Untitled Card" }
return title
}
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
if let lane { if let lane {
@@ -84,9 +106,20 @@ struct LaneScreen: View {
description: Text("Add a card to this lane.") description: Text("Add a card to this lane.")
) )
} else { } else {
// Already in display order (`Lane.cards`'s own contract) trusted rather than Picker("Sort", selection: sortOrder) {
// re-sorted here, exactly as the lane list trusts `BoardModel.lanes`. Text("Manual").tag(ItemSortOrder.manual)
ForEach(lane.cards) { card in Text("Name").tag(ItemSortOrder.name)
Text("Recent").tag(ItemSortOrder.recent)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
// The trusted order is `.manual`'s base `Lane.cards` is already in display
// order, exactly as the lane list trusts `BoardModel.lanes` and `.name`/
// `.recent` layer a display-only re-sort on top, never touching that base or
// what's on disk.
ForEach(sortOrder.wrappedValue.sorted(lane.cards, title: displayTitle(of:), modified: { $0.modified.value })) { card in
NavigationLink(value: BoardRoute.card(boardRoot: boardRoot, laneID: laneID, cardID: card.id)) { NavigationLink(value: BoardRoute.card(boardRoot: boardRoot, laneID: laneID, cardID: card.id)) {
CardSummaryRow(card: card) CardSummaryRow(card: card)
} }
@@ -168,7 +201,7 @@ struct LaneScreen: View {
} }
} }
/// One card row: title, an attachment-count hint, and the card's own icon when it has one. /// One card row: its icon when it has one, title, and an attachment-count hint.
/// ///
/// **No label chips.** `labels` is not a field `BoardModel`/`FrontmatterFields` expose see /// **No label chips.** `labels` is not a field `BoardModel`/`FrontmatterFields` expose see
/// `CardAttributesSection`'s doc comment for why so `attachments` (a field the model already /// `CardAttributesSection`'s doc comment for why so `attachments` (a field the model already
@@ -178,6 +211,7 @@ private struct CardSummaryRow: View {
var body: some View { var body: some View {
HStack { HStack {
ItemIconView(icon: card.icon.value, iconColor: card.iconColor.value)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(displayTitle) Text(displayTitle)
if !card.attachments.isEmpty { if !card.attachments.isEmpty {
@@ -186,11 +220,6 @@ private struct CardSummaryRow: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
Spacer()
if let icon = card.icon.value, !icon.isEmpty {
Image(systemName: icon)
.foregroundStyle(iconTint)
}
} }
} }
@@ -198,8 +227,4 @@ private struct CardSummaryRow: View {
guard let title = card.title.value, !title.isEmpty else { return "Untitled Card" } guard let title = card.title.value, !title.isEmpty else { return "Untitled Card" }
return title return title
} }
private var iconTint: Color {
card.iconColor.value.flatMap { CardPalette.color(named: $0, in: CardPalette.foregrounds) } ?? .secondary
}
} }
+23
View File
@@ -0,0 +1,23 @@
import SwiftUI
/// The leading icon a list row renders when its item has one the symbol name straight from
/// frontmatter, tinted through the palette with `.secondary` standing in for any name the
/// palette does not know. Decorative: the row's text is the accessible content.
struct ItemIconView: View {
let icon: String?
let iconColor: String?
var body: some View {
if let icon, !icon.isEmpty {
Image(systemName: icon)
.foregroundStyle(tint)
.accessibilityHidden(true)
}
}
/// An unrecognized colour name falls back to `.secondary` rather than guessing the same
/// leniency `CardPalette.color(named:in:)` documents for itself.
private var tint: Color {
iconColor.flatMap { CardPalette.color(named: $0, in: CardPalette.foregrounds) } ?? .secondary
}
}
+56
View File
@@ -0,0 +1,56 @@
import Foundation
/// How the lane and card lists order their rows. Raw values are persisted (`AppStorage`), so
/// they are API. `.manual` is the board's own arrangement the rank order the loader already
/// delivered and sorting here is display-only: it never rewrites ranks on disk.
enum ItemSortOrder: String, CaseIterable, Sendable {
case manual
case name
case recent
}
extension ItemSortOrder {
/// Orders `items` for display without touching the manual arrangement they arrived in.
///
/// **`.manual` is a no-op** `items` is already in the loader's rank order, so this returns it
/// unchanged.
///
/// **`.name` sorts by `title`**, case/diacritic-insensitive (`localizedStandardCompare`). Callers
/// pass the row's own display title the "Untitled Lane"/"Untitled Card" fallback already
/// applied so there is no separate nil case to sort here.
///
/// **`.recent` sorts by `modified` descending, with `nil` last.** Both sorts tie-break the same
/// way: Swift's `sort` is not a stable sort, so ties equal titles, equal or absent dates keep
/// the incoming order via each item's original offset, exactly as `BoardSortOrder.sorted` does.
nonisolated func sorted<Item>(
_ items: [Item],
title: (Item) -> String,
modified: (Item) -> Date?
) -> [Item] {
switch self {
case .manual:
return items
case .name:
return items.enumerated().sorted { lhs, rhs in
let comparison = title(lhs.element).localizedStandardCompare(title(rhs.element))
if comparison != .orderedSame { return comparison == .orderedAscending }
return lhs.offset < rhs.offset
}.map(\.element)
case .recent:
return items.enumerated().sorted { lhs, rhs in
switch (modified(lhs.element), modified(rhs.element)) {
case let (l?, r?) where l != r:
return l > r
case (nil, .some):
return false
case (.some, nil):
return true
default:
return lhs.offset < rhs.offset
}
}.map(\.element)
}
}
}