docs/superpowers/plans/2026-09-22-1.5.0-markup-previews.md

main
gitbay-ios/docs/superpowers/plans/2026-09-22-1.5.0-markup-previews.md rendered · source · history · blame · raw

433 lines · 18518 bytes

6 symbols in this file

1.5.0 Markup Previews Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Every editor that takes markup — issue and MR bodies and comments, release notes, and a markup file in the file editor — can show its draft rendered before it is sent.

Architecture: One shared view, MarkupPreview, a collapsed "Preview" disclosure row placed under each editor. Expanded, it renders the draft with FormattedBodyView, the view the app's threads already render bodies and comments with, so the preview is what the app will show. A static format(forFile:) decides whether a file in the editor renders at all, and as which format.

Tech Stack: Swift 6 language mode with default MainActor isolation, SwiftUI, Swift Testing, XCUITest. iOS 18.0 minimum, iOS 26.5 SDK. Rendering through OrgSwiftUI and the app's MarkdownView.

Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md (section "1.5.0: markup previews")

Global Constraints

  • Never attribute anything to an assistant or model — not in commits, code comments, MR bodies, or docs. No Co-Authored-By trailer. This is absolute.

  • Commits are signed. commit.gpgsign is on; do not pass --no-gpg-sign.

  • Never push to main. Never merge. One branch, markup-preview, cut from main after 1.4.0 has merged. Task 4 is the version bump and waits for the user to merge it.

  • File-system-synchronized groups. New .swift files under gitbay/ and gitbayTests/ are picked up automatically. Do not edit project.pbxproj except for the version lines in Task 4.

  • The preview promises the app's rendering, not the web's. Neither the app's threads nor the preview resolve autolinks (#N, owner/name#N). Say nothing in copy or comments that implies otherwise.

  • Design tokens only. Fonts are .gbSans(…)/.gbMono(…); colours come from the gb palette or the system semantic styles. No new visual treatment: a DisclosureGroup row in the existing Form/List is the whole UI.

  • Comments explain why, in plain direct English, at the density of the surrounding code. No before/after commentary.

  • Run the whole unit target, and read the count.

    cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \
      -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \
      -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20
    xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \
      | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])"
    

    "Run the unit target" below means these two commands. Record the count before Task 1 as BASE.


Task 1: MarkupPreview

Files:

  • Create: gitbay/Views/Shared/MarkupPreview.swift
  • Create: gitbayTests/MarkupPreviewTests.swift

Interfaces:

  • Consumes: FormattedBodyView(text: String, format: String?) in gitbay/Views/Shared/.

  • Produces: MarkupPreview(text: String, format: String?, identifier: String); static func MarkupPreview.format(forFile name: String) -> String? returning "org", "md" or nil.

  • Step 1: Branch

cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c markup-preview

Run the unit target and record the count as BASE.

  • Step 2: Write the failing tests

Create gitbayTests/MarkupPreviewTests.swift:

import Testing
@testable import gitbay

struct MarkupPreviewFormatTests {

    @Test func orgFilesRenderAsOrg() {
        #expect(MarkupPreview.format(forFile: "README.org") == "org")
        #expect(MarkupPreview.format(forFile: "profile/README.org") == "org")
    }

    @Test func markdownFilesRenderAsMarkdown() {
        #expect(MarkupPreview.format(forFile: "README.md") == "md")
        #expect(MarkupPreview.format(forFile: "notes.markdown") == "md")
        #expect(MarkupPreview.format(forFile: "CHANGES.MD") == "md")
    }

    /// Source files show as source, so there is nothing to preview.
    @Test func otherFilesHaveNoPreview() {
        #expect(MarkupPreview.format(forFile: "main.swift") == nil)
        #expect(MarkupPreview.format(forFile: "Makefile") == nil)
        #expect(MarkupPreview.format(forFile: "notes.txt") == nil)
    }
}
  • Step 3: Run the unit target to verify it fails

Expected: build FAILS with cannot find 'MarkupPreview' in scope.

  • Step 4: Create the view

Create gitbay/Views/Shared/MarkupPreview.swift:

import SwiftUI

/// A collapsed "Preview" row under a markup editor. Expanded, it renders
/// the draft with `FormattedBodyView`, the view the app's threads use, so
/// what it shows is what the app will show once the text is sent. It sits
/// under the editor rather than replacing it, so the draft stays in view.
struct MarkupPreview: View {

    let text: String
    let format: String?
    let identifier: String

    @State private var expanded = false

    var body: some View {
        DisclosureGroup("Preview", isExpanded: $expanded) {
            if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                Text("Nothing to preview.")
                    .font(.gbSans(.subheadline))
                    .foregroundStyle(.secondary)
            } else {
                FormattedBodyView(text: text, format: format)
                    .padding(.vertical, 4)
            }
        }
        .font(.gbSans(.subheadline))
        .accessibilityIdentifier(identifier)
    }

    /// The format a file's contents render in, or nil for a file shown as
    /// source. The same extensions the forge renders a README or wiki
    /// page from.
    static func format(forFile name: String) -> String? {
        switch (name as NSString).pathExtension.lowercased() {
        case "org": "org"
        case "md", "markdown": "md"
        default: nil
        }
    }
}
  • Step 5: Run the unit target to verify it passes

Expected: Passed <BASE + 3> 0.

  • Step 6: Commit
cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Views/Shared/MarkupPreview.swift gitbayTests/MarkupPreviewTests.swift && git commit -m "views: MarkupPreview, a draft rendered as the thread renders it"

Task 2: Put it under every markup editor

Files:

  • Modify: gitbay/Views/Shared/ComposeSheet.swift:63-75 (Body section)
  • Modify: gitbay/Views/MRs/MRListView.swift:114-125 (MRCreateSheet Body section)
  • Modify: gitbay/Views/Issues/IssueView.swift:203-226 (comment row)
  • Modify: gitbay/Views/MRs/MRView.swift:538-561 (comment row)
  • Modify: gitbay/Views/Releases/ReleaseListView.swift:106-110 (Notes section)
  • Modify: gitbay/Views/Repos/FileEditSheet.swift:18-28 (content section)

Interfaces:

  • Consumes: MarkupPreview(text:format:identifier:), MarkupPreview.format(forFile:) (Task 1); FileViewModel.fileName: String.
  • Produces: accessibility identifiers compose-preview, mr-create-preview, issue-comment-field, issue-comment-preview, mr-comment-field, mr-comment-preview, release-notes-preview, file-edit-preview.

Line numbers are as of main after 1.4.0; find each block by its content.

  • Step 1: Issue and MR create/edit, release edit

In gitbay/Views/Shared/ComposeSheet.swift, in the Section("Body"), after the if showsFormat { Picker("Format", …) … } block, add:

                    MarkupPreview(text: bodyText, format: format, identifier: "compose-preview")

format is the constant "md" where the sheet shows no picker, which is what the release editor sends.

  • Step 2: MR create

In gitbay/Views/MRs/MRListView.swift, in MRCreateSheet's Section("Body"), after the Picker("Format", selection: $format) { … } block with its modifiers, add:

                    MarkupPreview(text: bodyText, format: format, identifier: "mr-create-preview")
  • Step 3: Issue comments

In gitbay/Views/Issues/IssueView.swift, in the comment HStack, give the field an identifier: change

                TextField("Comment", text: $commentText, axis: .vertical)
                    .lineLimit(1...5)

to

                TextField("Comment", text: $commentText, axis: .vertical)
                    .lineLimit(1...5)
                    .accessibilityIdentifier("issue-comment-field")

and immediately after the closing brace of that HStack, add:

            if !commentText.isEmpty {
                MarkupPreview(text: commentText, format: commentFormat,
                              identifier: "issue-comment-preview")
            }
  • Step 4: MR comments

In gitbay/Views/MRs/MRView.swift, make the same two changes to the comment HStack with the identifiers mr-comment-field and mr-comment-preview:

                TextField("Comment", text: $commentText, axis: .vertical)
                    .lineLimit(1...5)
                    .accessibilityIdentifier("mr-comment-field")
            if !commentText.isEmpty {
                MarkupPreview(text: commentText, format: commentFormat,
                              identifier: "mr-comment-preview")
            }
  • Step 5: Release notes on create

In gitbay/Views/Releases/ReleaseListView.swift, replace

                Section("Notes") {
                    TextEditor(text: $notes)
                        .frame(minHeight: 140)
                        .autocorrectionDisabled()
                }

with

                Section("Notes") {
                    TextEditor(text: $notes)
                        .frame(minHeight: 140)
                        .autocorrectionDisabled()
                    // release create sends no --format, and release notes
                    // render as markdown.
                    MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview")
                }
  • Step 6: Markup files in the file editor

In gitbay/Views/Repos/FileEditSheet.swift, after the Section { TextEditor(text: $content) … } header: { … } footer: { … } block and before Section("Message"), add:

                if let format = MarkupPreview.format(forFile: model.fileName) {
                    Section {
                        MarkupPreview(text: content, format: format, identifier: "file-edit-preview")
                    }
                }
  • Step 7: Build and run the unit target

Expected: Passed <BASE + 3> 0. The call sites have no unit tests; Task 3's live step drives one end to end.

  • Step 8: Check the design greps
cd /Users/cmc/git/krz/gitbay-ios && grep -rnE '\.(green|red|blue|orange|yellow|purple)\b' gitbay/ --include=*.swift | grep -v gb; \
grep -rn '\.font(\.' gitbay/Views/Shared/MarkupPreview.swift | grep -vE 'gbSans|gbMono'

Expected: no output from either.

  • Step 9: Commit
cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Views/Shared/ComposeSheet.swift gitbay/Views/MRs/MRListView.swift gitbay/Views/Issues/IssueView.swift gitbay/Views/MRs/MRView.swift gitbay/Views/Releases/ReleaseListView.swift gitbay/Views/Repos/FileEditSheet.swift && git commit -m "Preview under every markup editor

Issue and MR bodies on create and edit, their comments, release notes,
and a .md, .markdown or .org file in the file editor."

Task 3: Live step, then the MR

Files:

  • Modify: gitbayUITests/LiveSmokeUITests.swift (new testMarkupPreviewFlow() and its setUpWithError guard)

Interfaces:

  • Consumes: Self.fixtureRepo, Self.fixtureIssueTitle, requireFixtureRepo(), ensureFixtureIssue(), openRepo(_:), scrollTo(_:swipes:); identifiers issue-comment-field, issue-comment-preview (Task 2).

The test types a comment and never sends it.

  • Step 1: Add the fixture guard

In setUpWithError(), after the last if name.contains(…) block, add:

        if name.contains("testMarkupPreviewFlow") {
            try requireFixtureRepo()
            ensureFixtureIssue()
        }
  • Step 2: Add the test

Add after the last test in the class:

    /// A comment's preview renders its markup before anything is sent:
    /// the bold markers are gone and the words remain. Nothing is posted.
    func testMarkupPreviewFlow() throws {
        openRepo(Self.fixtureRepo)
        app.staticTexts["Issues"].firstMatch.tap()
        let issue = app.staticTexts[Self.fixtureIssueTitle].firstMatch
        XCTAssertTrue(issue.waitForExistence(timeout: 15), "fixture issue not listed")
        issue.tap()

        let field = app.descendants(matching: .any)
            .matching(identifier: "issue-comment-field").firstMatch
        XCTAssertTrue(scrollTo(field, swipes: 10), "no comment field on the issue")
        field.tap()
        let draft = "**ui-smoke preview**"
        field.typeText(draft)

        let preview = app.descendants(matching: .any)
            .matching(identifier: "issue-comment-preview").firstMatch
        XCTAssertTrue(scrollTo(preview), "no preview row for a non-empty comment")
        preview.tap()
        XCTAssertTrue(app.staticTexts
            .containing(NSPredicate(format: "label CONTAINS 'ui-smoke preview'")).firstMatch
            .waitForExistence(timeout: 5), "preview did not render the draft")
        XCTAssertFalse(app.staticTexts
            .containing(NSPredicate(format: "label CONTAINS '**ui-smoke'")).firstMatch.exists,
            "preview shows the raw markup")

        // Leave the fixture as found: clear the draft, send nothing.
        XCTAssertTrue(scrollTo(field), "comment field scrolled away")
        field.tap()
        field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: draft.count))
        XCTAssertFalse(preview.exists, "preview row outlived an empty comment")
    }
  • Step 3: Build the UI test target
cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \
  -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' build-for-testing 2>&1 | grep -E "error:|TEST BUILD (SUCCEEDED|FAILED)" | tail -5

Expected: ** TEST BUILD SUCCEEDED **.

  • Step 4: Run the live test (needs a token from the user)

Ask the user for an ios-smoke token (ssh -F /dev/null -i ~/.ssh/gitbay-ios-smoke -o IdentitiesOnly=yes git@gitbay.org token create --name ui-smoke --scope full --ttl 1d).

cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \
  xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \
  -only-testing:gitbayUITests/LiveSmokeUITests/testMarkupPreviewFlow \
  -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10

Expected: passed. If the markdown renderer splits the bold run into a separate element whose label is exactly ui-smoke preview, the CONTAINS predicate still matches.

  • Step 5: Look at one preview in the simulator

Launch the app on the iPhone 17 Pro simulator, open any issue's New Issue sheet, type a heading and a list in Org format, expand Preview, and screenshot it in light and dark. Confirm it uses the same type and colours as a rendered comment in a thread. Record the result in the MR body.

  • Step 6: Commit, push, open the MR
cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: a comment preview renders its markup" && \
git push -u origin markup-preview && gitbay mr create --source markup-preview --target main \
  --title "Preview markup before sending" --file - <<'EOF'
A collapsed Preview row under every markup editor: issue and MR create
and edit, their comments, release create and edit, and a .md,
.markdown or .org file in the file editor. It renders the draft with
FormattedBodyView, the view the app's threads use, so it shows what the
app will show. Like the threads, it resolves no autolinks; the web's
preview does.

Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md
EOF

Task 4: Bump to 1.5.0 (14)

Starts only after the user has merged the MR from Task 3.

Files:

  • Modify: gitbay.xcodeproj/project.pbxproj (the four version lines)

  • Step 1: Branch, bump, verify

cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c bump-1.5.0 && \
sed -i '' -e 's/MARKETING_VERSION = 1.4.0;/MARKETING_VERSION = 1.5.0;/' \
  -e 's/CURRENT_PROJECT_VERSION = 13;/CURRENT_PROJECT_VERSION = 14;/' gitbay.xcodeproj/project.pbxproj && \
git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj

Expected: 1 file changed, 4 insertions(+), 4 deletions(-) and OK.

  • Step 2: Both runtimes and a Release device build

Run the unit target on iPhone 17 Pro (26.5), then:

cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \
  -destination 'id=9F8E5D84-0A15-4C64-8A45-994667A73817' -only-testing:gitbayTests test 2>&1 | grep -E "TEST (SUCCEEDED|FAILED)"
xcodebuild build -scheme gitbay -configuration Release -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO 2>&1 \
  | grep -E "error:|While running pass|BUILD (SUCCEEDED|FAILED)" | tail -5

Expected: ** TEST SUCCEEDED ** and ** BUILD SUCCEEDED **.

  • Step 3: Commit, push, open the MR
cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.5.0 (14)" && \
git push -u origin bump-1.5.0 && gitbay mr create --source bump-1.5.0 --target main --title "Bump to 1.5.0 (14)" --body "Markup previews."

Tagging v1.5.0, archiving and uploading are the user's.

  • Step 4: Flip the Parity rows upstream

Once 1.5.0 is released, in ~/git/krz/gitbay on a new branch parity-ios-1.5.0, set the iOS column to yes for preview body markup (Merge requests and Issues), preview an edited markup file and preview release notes. Commit, push, and open the MR titled "Parity: iOS 1.5.0 rows".