From 2516c4ba5dfd073fcea83ff9236e2ec45c6a0851 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 8 Aug 2026 15:22:36 -0400 Subject: [PATCH] =?UTF-8?q?The=20phone=20learns=20to=20read=20whole=20bloc?= =?UTF-8?q?ks=20=E2=80=94=20CardBodyView=20renders=20BodyMarkup's=20full?= =?UTF-8?q?=20tree,=20and=20the=20title=20falls=20in=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read view's inline-only AttributedString shim gives way to a real block renderer over BodyMarkup — the same parse the Mac Preview trusts, whose swift-markdown dependency project.yml said was already riding along for exactly this surface. Headings, nested lists with static task checkboxes, quotes, GFM tables with per-column alignment and clamped colspans, sideways-scrolling code blocks, literal HTML, dividers, and tappable absolute links (relative ones stay prose — no folder to resolve against). The title block sheds its inner padding so its leading edge sits flush with the body; the tint now grows outward via a negative background inset instead of pushing the text in. One rich fixture card body enriched to exercise every block kind; Mac fixture round-trip suites verified green against it. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY --- .../index.md | 19 +- KanbanMobile/CHANGELOG.md | 2 + KanbanMobile/Screens/CardDetailScreen.swift | 71 +-- KanbanMobile/UI/CardBodyView.swift | 443 ++++++++++++++++++ 4 files changed, 485 insertions(+), 50 deletions(-) create mode 100644 KanbanMobile/UI/CardBodyView.swift diff --git a/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md index 3abaf8b..86cf08a 100644 --- a/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md +++ b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/40000000-0000-4000-8000-000000000004/index.md @@ -6,4 +6,21 @@ background: {color: coral} icon: flag.fill iconColor: orange --- -Plain, unquoted title this time — mixing styles on purpose. +## Stray tolerance + +Plain, unquoted title this time — mixing styles on purpose, and a body rich enough to exercise +every block the mobile Preview renders. + +- [x] Skip `.DS_Store` and other dotfiles +- [ ] Warn on non-UUID folder names + +> Any folder that isn't a UUID is treated as an opaque stray, never parsed as an item. + +| Case | Behavior | +| --- | --- | +| Dotfile | Skipped silently | +| Non-UUID folder | Treated as a stray | + +```swift +let isUUID = UUID(uuidString: name) != nil +``` diff --git a/KanbanMobile/CHANGELOG.md b/KanbanMobile/CHANGELOG.md index 854f513..d8548c0 100644 --- a/KanbanMobile/CHANGELOG.md +++ b/KanbanMobile/CHANGELOG.md @@ -1,5 +1,7 @@ **August 2026** +Card text now renders full Markdown — headings, lists with checkboxes, quotes, tables, and code blocks — instead of plain paragraphs. + Tapping a card now shows a readable view of its title and text with formatting, instead of dropping straight into the editor. Editing a card happens on its own screen with Details and Body sections, and changes apply only when you tap Save. diff --git a/KanbanMobile/Screens/CardDetailScreen.swift b/KanbanMobile/Screens/CardDetailScreen.swift index b8cfaab..a0315b9 100644 --- a/KanbanMobile/Screens/CardDetailScreen.swift +++ b/KanbanMobile/Screens/CardDetailScreen.swift @@ -75,18 +75,32 @@ struct CardDetailScreen: View { /// card's own `background` swatch — passive display only, no swatches to tap here. An /// unrecognized colour name (the same leniency `ItemIconView`/`CardPalette` document for /// themselves) just skips the tint rather than guessing at one. + /// + /// **Alignment constraint.** The title's leading edge has to land exactly where the body's + /// paragraphs land, one block below — both sit at nothing but the `ScrollView`'s own outer + /// `.padding()`, so this view carries no padding of its own around the icon/text content. The + /// tint still wants breathing room rather than painting flush against the letterforms, but + /// that room can't come from padding the content — that's exactly what would reintroduce the + /// indent this fixes. It comes from the tint shape growing *outward* past the content's own + /// bounds instead, via a negative inset in `.background`, which is invisible to layout: the + /// row's frame — and so the title's leading edge — is unaffected by how far its background + /// paints past it. A card with no tint (most of them) renders with zero indent, flush with the + /// body below; a tinted card's title sits in exactly the same place, just with a soft wash + /// bleeding past its edges. @ViewBuilder private func titleBlock(for card: Card) -> some View { HStack(alignment: .firstTextBaseline, spacing: 10) { ItemIconView(icon: card.icon.value, iconColor: card.iconColor.value) titleText(for: card) } - .padding() .frame(maxWidth: .infinity, alignment: .leading) - .background( - (tintColor(for: card)?.opacity(0.15) ?? Color.clear), - in: RoundedRectangle(cornerRadius: 12) - ) + .background { + if let tintColor = tintColor(for: card) { + RoundedRectangle(cornerRadius: 12) + .fill(tintColor.opacity(0.15)) + .padding(-10) + } + } } @ViewBuilder @@ -105,21 +119,11 @@ struct CardDetailScreen: View { card.background.value.flatMap { CardPalette.color(named: $0, in: CardPalette.backgrounds) } } - /// The body, rendered as Markdown — see `MarkdownBlocks` for why this is a per-paragraph - /// `AttributedString(markdown:)` pass rather than a full block parser. + /// The body, rendered as Markdown — see `CardBodyView` for the block-by-block renderer this + /// delegates to, built on the same `BodyMarkup` model the Mac's Preview surface parses with. @ViewBuilder private func bodyBlock(for card: Card) -> some View { - let paragraphs = MarkdownBlocks.paragraphs(in: card.body) - if paragraphs.isEmpty { - Text("No description") - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 12) { - ForEach(Array(paragraphs.enumerated()), id: \.offset) { _, paragraph in - Text(MarkdownBlocks.rendered(paragraph)) - } - } - } + CardBodyView(body: card.body) } @ViewBuilder @@ -137,34 +141,3 @@ struct CardDetailScreen: View { date.formatted(date: .abbreviated, time: .shortened) } } - -/// Dependency-free Markdown rendering for a card body: no `swift-markdown` block parser here, just -/// `Foundation`'s own `AttributedString(markdown:options:)` run once per paragraph. -/// -/// **Why per-paragraph.** `AttributedString`'s built-in parser has no "render this as a sequence of -/// blocks" mode short of `.full`, which produces a block tree this view would still have to walk — -/// no simpler than doing the walk ourselves, and it collapses the very newlines a card body relies -/// on to separate paragraphs. Splitting on blank lines first and parsing each paragraph with -/// `.inlineOnlyPreservingWhitespace` gets both halves of what a card body needs cheaply: inline -/// emphasis/links/code render as styled runs, and the newlines *inside* a paragraph (soft line -/// breaks) survive as literal whitespace instead of being folded into a single space. -enum MarkdownBlocks { - - /// Splits `body` on runs of one or more blank lines — Markdown's own paragraph boundary — and - /// trims each block, so a body with trailing blank lines or extra spacing between paragraphs - /// doesn't render phantom empty blocks. - static func paragraphs(in body: String) -> [String] { - body - .components(separatedBy: "\n\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - - /// One paragraph, inline-parsed. Malformed Markdown (an unterminated code span, say) falls back - /// to the raw paragraph text rather than dropping it — the same "never lose the author's text" - /// posture `CardPalette.color(named:in:)` takes for an unrecognized colour name. - static func rendered(_ paragraph: String) -> AttributedString { - let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) - return (try? AttributedString(markdown: paragraph, options: options)) ?? AttributedString(paragraph) - } -} diff --git a/KanbanMobile/UI/CardBodyView.swift b/KanbanMobile/UI/CardBodyView.swift new file mode 100644 index 0000000..ce5fcd9 --- /dev/null +++ b/KanbanMobile/UI/CardBodyView.swift @@ -0,0 +1,443 @@ +import SwiftUI + +/// A card body, rendered — the phone's counterpart to the Mac's `BodyMarkupRenderer`, and the +/// anticipated surface `project.yml`'s dependency comment names: "`BodyMarkup` imports `Markdown`, +/// so the dependency rides along even though the MVP has no preview surface yet." This is that +/// surface arriving. +/// +/// **Same model, different substrate, same semantics where it matters.** `BodyMarkup.parse(_:)` — +/// the shared parse the Mac's Preview also runs — already made every decision that is not +/// typography: HTML is literal text, an image is never fetched, a task's live offset lives on the +/// model rather than the render. What is left here is exactly what was left for +/// `BodyMarkupRenderer`: turning a `[BodyBlock]` into pixels, on a different toolkit (SwiftUI +/// `Text`/`AttributedString` rather than `NSTextView`/`NSTextTable`), and read-only — there is no +/// tap-to-toggle checkbox this pass; a static SF Symbol stands in, and flipping it live is a +/// separate future decision (see `TaskMarkerView` below). +/// +/// **Why a block renderer at all, and not the inline-only `AttributedString(markdown:)` shim this +/// replaces.** That shim split on blank lines and parsed each paragraph as inline Markdown, which +/// covers emphasis, links, and code spans but nothing block-shaped: a card body with a heading, a +/// fenced code sample, a list, or a table rendered as an indistinguishable paragraph of stray +/// punctuation. `BodyMarkup` already parses the full block tree — the walk this view does is the +/// one the shim's own doc comment predicted would be "no simpler than doing the walk ourselves", +/// except now there is a real block tree to walk instead of a hand-split string. +struct CardBodyView: View { + private let blocks: [BodyBlock] + + init(body: String) { + blocks = BodyMarkup.parse(body).blocks + } + + var body: some View { + if blocks.isEmpty { + Text("No description") + .foregroundStyle(.secondary) + } else { + BlockListView(blocks: blocks, spacing: 14) + } + } +} + +// MARK: - Block layout + +/// A run of sibling blocks, stacked top to bottom — the shape every block-container case in +/// `BodyBlock` recurses through: the document's own top level, a list item's content, a quote's +/// nested blocks, a table cell. One view handles all of them, which is what makes the recursion +/// (quotes inside quotes, lists inside lists) fall out for free rather than needing a depth +/// parameter threaded everywhere. +private struct BlockListView: View { + let blocks: [BodyBlock] + var spacing: CGFloat = 10 + + var body: some View { + VStack(alignment: .leading, spacing: spacing) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + BlockView(block: block) + } + } + } +} + +private struct BlockView: View { + let block: BodyBlock + + var body: some View { + switch block { + case let .heading(level, inlines, _): + Text(InlineText.render(inlines, font: Self.headingFont(level: level))) + .padding(.top, level <= 2 ? 4 : 0) + + case let .paragraph(inlines, _): + Text(InlineText.render(inlines)) + + case let .code(code, _, _): + LiteralBlockView(text: Self.trimmingTrailingNewlines(code), color: .primary) + + case let .html(raw, _): + // Verbatim, same rule the model itself makes: `x` is code-styled characters, + // never a rendered bold. Dimmed relative to code to read as "the author's markup", + // not "content" — the Mac renderer's own choice for this case. + LiteralBlockView(text: Self.trimmingTrailingNewlines(raw), color: .secondary) + + case .thematicBreak: + Divider() + .padding(.vertical, 4) + + case let .quote(blocks, _): + QuoteView(blocks: blocks) + + case let .list(list, _): + ListView(list: list) + + case let .table(table, _): + TableView(table: table) + } + } + + /// The heading ladder the spec asks for: a three-step title2/title3/headline run rather than + /// the Mac's continuous four-step scale, because that is the whole of SwiftUI's built-in text + /// style vocabulary above body weight — anything past level 3 stays at `.headline` rather than + /// inventing a fourth size nothing else on the screen uses. + fileprivate static func headingFont(level: Int) -> Font { + switch level { + case 1: .title2.weight(.bold) + case 2: .title3.weight(.bold) + default: .headline + } + } + + fileprivate static func trimmingTrailingNewlines(_ text: String) -> String { + var text = text + while text.hasSuffix("\n") || text.hasSuffix("\r") { text.removeLast() } + return text + } +} + +/// A fenced/indented code block or a literal HTML block: monospaced, in a soft rounded container, +/// scrolling sideways rather than wrapping — the shape a code sample or a raw tag soup needs to +/// stay legible instead of ricocheting across narrow phone-width lines. +private struct LiteralBlockView: View { + let text: String + let color: Color + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + Text(text) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(color) + .padding(10) + } + .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8)) + } +} + +/// A block quote: a leading accent bar beside its nested blocks, recursing through `BlockListView` +/// for whatever those blocks are — including another quote, which is what makes a quote-in-a-quote +/// read as two bars rather than something this view has to special-case. +private struct QuoteView: View { + let blocks: [BodyBlock] + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Capsule() + .fill(Color.secondary.opacity(0.35)) + .frame(width: 3) + BlockListView(blocks: blocks, spacing: 8) + } + } +} + +// MARK: - Lists + +/// A bullet, ordered, or task list. Nested lists are not a case this view special-cases either: +/// `BodyListItem.blocks` recurses through `BlockListView` just like a quote's blocks do, and +/// because a nested list lands *inside* the marker/content `HStack` of the item that owns it, the +/// indentation the spec asks for ("nested lists indent") falls out of that layout rather than +/// needing a manually tracked depth. +private struct ListView: View { + let list: BodyList + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(list.items.enumerated()), id: \.offset) { offset, item in + ListItemView(item: item, marker: marker(for: item, index: offset)) + } + } + } + + private func marker(for item: BodyListItem, index: Int) -> ListMarker { + if let task = item.task { return .task(isChecked: task.isChecked) } + return list.isOrdered ? .number(list.start + index) : .bullet + } +} + +private enum ListMarker { + case bullet + case number(Int) + case task(isChecked: Bool) +} + +private struct ListItemView: View { + let item: BodyListItem + let marker: ListMarker + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + markerView + if item.blocks.isEmpty { + // A bare `- [ ]` with no label yet is still a checkbox the user wrote — keep the + // marker on screen rather than collapsing the row to nothing, the same posture + // `BodyMarkupRenderer.append(_:BodyList:...)` takes on the Mac. + Text(" ") + } else { + BlockListView(blocks: item.blocks, spacing: 8) + } + } + } + + @ViewBuilder + private var markerView: some View { + switch marker { + case .bullet: + Text("•") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + + case let .number(number): + Text("\(number).") + .foregroundStyle(.secondary) + .monospacedDigit() + .accessibilityHidden(true) + + case let .task(isChecked): + TaskMarkerView(isChecked: isChecked) + } + } +} + +/// A task-list checkbox, drawn but not driven: `checkmark.square.fill` when checked, `square` +/// otherwise, both in a secondary tint. The Mac's Preview makes this glyph clickable — a `.link` +/// URL that round-trips through `CardBodyLink` to flip the one byte `BodyTask.markerOffset` names +/// — but that write path (and the re-verification `BoardWriter.toggleTaskMarker` does against +/// disk before committing it) is deliberately not part of this pass: `CardDetailScreen` is a +/// read-only screen with no session write of its own, and giving one glyph inside it a live write +/// would be a bigger decision than this pass is scoped to make. A future pass that wants tappable +/// checkboxes here can lean on the same model-carried offset; nothing about `BodyTask` needs to +/// change to support it. +private struct TaskMarkerView: View { + let isChecked: Bool + + var body: some View { + Image(systemName: isChecked ? "checkmark.square.fill" : "square") + .foregroundStyle(.secondary) + .accessibilityLabel(isChecked ? "Completed task" : "Incomplete task") + } +} + +// MARK: - Tables + +/// A GFM table as a `Grid`: per-column alignment from `BodyTable.alignments`, a bold header row, +/// and a colspan handled with `.gridCellColumns` — `Grid`'s own multi-column span primitive, which +/// is to this layout what `NSTextTable`'s automatic sizing is to the Mac's: the one built-in +/// mechanism that makes a hand-rolled tab-stop table unnecessary. Wrapped in a horizontal +/// `ScrollView` because a table sized to its content is routinely wider than a phone screen. +private struct TableView: View { + let table: BodyTable + + private var columnCount: Int { max(1, table.columnCount) } + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 8) { + row(table.header, isHeader: true) + Divider().gridCellColumns(columnCount) + ForEach(Array(table.rows.enumerated()), id: \.offset) { _, cells in + row(cells, isHeader: false) + } + } + .padding(.vertical, 2) + } + } + + @ViewBuilder + private func row(_ cells: [BodyTableCell], isHeader: Bool) -> some View { + GridRow { + ForEach(positioned(cells), id: \.column) { positioned in + cellView(positioned.cell, column: positioned.column, span: positioned.span, isHeader: isHeader) + } + } + } + + /// Cells with their starting grid column and clamped span, walked left to right so a colspan + /// earlier in the row shifts every cell after it — the same bookkeeping `BodyTableRenderer`'s + /// `NSTextTableBlock(startingColumn:columnSpan:)` construction does on the Mac, done here in + /// terms `Grid` understands. A span that would run past the table's own column count is + /// clamped rather than trusted — "handled at least degenerately, don't crash" — and a row with + /// more cells than there is room for simply stops placing them. + private func positioned(_ cells: [BodyTableCell]) -> [(cell: BodyTableCell, column: Int, span: Int)] { + var column = 0 + var result: [(cell: BodyTableCell, column: Int, span: Int)] = [] + for cell in cells where column < columnCount { + let span = max(1, min(cell.colspan, columnCount - column)) + result.append((cell, column, span)) + column += span + } + return result + } + + @ViewBuilder + private func cellView(_ cell: BodyTableCell, column: Int, span: Int, isHeader: Bool) -> some View { + let alignment = table.alignments.indices.contains(column) ? table.alignments[column] : .unspecified + Text(InlineText.render(cell.inlines, font: isHeader ? .body.weight(.semibold) : .body)) + .multilineTextAlignment(textAlignment(alignment)) + .gridColumnAlignment(horizontalAlignment(alignment)) + .gridCellColumns(span) + } + + private func horizontalAlignment(_ alignment: BodyTable.Alignment) -> HorizontalAlignment { + switch alignment { + case .unspecified, .leading: .leading + case .center: .center + case .trailing: .trailing + } + } + + private func textAlignment(_ alignment: BodyTable.Alignment) -> TextAlignment { + switch alignment { + case .unspecified, .leading: .leading + case .center: .center + case .trailing: .trailing + } + } +} + +// MARK: - Inlines + +/// `[BodyInline]` → one `AttributedString`, the whole of the phone's inline vocabulary in a single +/// recursive walk. Traits (bold/italic/strikethrough) are carried down as state and composed at +/// the leaves rather than each node picking its own font, the same reasoning +/// `BodyMarkupRenderer.InlineTraits` gives on the Mac: `**bold _and italic_**` nests two inline +/// cases around one run of text, and the run needs to know about both by the time it is drawn. +private enum InlineText { + + struct Traits { + var isBold = false + var isItalic = false + var isStruck = false + } + + static func render(_ inlines: [BodyInline], font: Font = .body) -> AttributedString { + var result = AttributedString() + append(inlines, into: &result, font: font, traits: Traits()) + return result + } + + private static func append( + _ inlines: [BodyInline], + into result: inout AttributedString, + font: Font, + traits: Traits + ) { + for inline in inlines { + append(inline, into: &result, font: font, traits: traits) + } + } + + private static func append( + _ inline: BodyInline, + into result: inout AttributedString, + font: Font, + traits: Traits + ) { + switch inline { + case let .text(text): + result += run(text, font: font, traits: traits) + + case let .emphasis(children): + var inner = traits + inner.isItalic = true + append(children, into: &result, font: font, traits: inner) + + case let .strong(children): + var inner = traits + inner.isBold = true + append(children, into: &result, font: font, traits: inner) + + case let .strikethrough(children): + var inner = traits + inner.isStruck = true + append(children, into: &result, font: font, traits: inner) + + case let .code(code): + var span = AttributedString(code) + span.font = .system(.body, design: .monospaced) + span.backgroundColor = Color(.systemGray5) + if traits.isStruck { span.strikethroughStyle = .single } + result += span + + case let .html(raw): + // Same rule as the block form: what the author typed is what the reader sees, styled + // to read as markup rather than prose. + var span = AttributedString(raw) + span.font = .system(.body, design: .monospaced) + span.foregroundColor = .secondary + span.backgroundColor = Color(.systemGray5) + result += span + + case .lineBreak: + // A hard break: a line of its own. + result += run("\n", font: font, traits: traits) + + case .softBreak: + // An ordinary newline in the source, a space to the reader — the same fold the Mac + // renderer performs (`NSAttributedString` has no soft-wrap character either; the + // difference from a hard break there is exactly this same one-space-versus-a-line + // choice, made in `AttributedString` terms here instead of `NSAttributedString`'s). + result += run(" ", font: font, traits: traits) + + case let .link(target, children): + var span = AttributedString() + append(children, into: &span, font: font, traits: traits) + if span.characters.isEmpty { + span = run(target.text, font: font, traits: traits) + } + // Only a scheme-carrying destination becomes a live `.link` — a relative path (a + // sibling file in the card's own folder, the Mac's "open with its default app" case) + // has nothing to resolve against here: this initializer takes only the body string, + // no card folder, and a phone app has no "open this arbitrary file with its default + // app" affordance to hand it to regardless. It still reads as the label the author + // wrote; it just is not tappable, rather than guessing at a destination. + if case .absolute = target, let url = URL(string: target.text) { + span.link = url + // The children were appended with an explicit `.primary` foreground (every run + // gets one), and an explicit colour beats the tint `Text` would otherwise paint a + // `.link` run with — clearing it is what lets a live link *look* live instead of + // rendering as plain body text that happens to respond to a tap. + span.foregroundColor = nil + } + result += span + + case let .image(image): + // No image loading this pass, on either fork of `BodyTarget` — the alt text (or the + // raw destination, if the author left no alt) stands in behind a paperclip glyph, so + // the reader learns what is not being shown without this view touching the network or + // the filesystem. + let label = image.alt.isEmpty ? image.target.text : image.alt + var span = AttributedString("\u{1F4CE} \(label)") + span.font = font + span.foregroundColor = .secondary + result += span + } + } + + private static func run(_ text: String, font: Font, traits: Traits) -> AttributedString { + var span = AttributedString(text) + var styledFont = font + if traits.isBold { styledFont = styledFont.bold() } + if traits.isItalic { styledFont = styledFont.italic() } + span.font = styledFont + span.foregroundColor = .primary + if traits.isStruck { span.strikethroughStyle = .single } + return span + } +}