docs/superpowers/plans/2026-09-22-1.5.0-markup-previews.md
433 lines · 18518 bytes
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-Bytrailer. This is absolute. -
Commits are signed.
commit.gpgsignis on; do not pass--no-gpg-sign. -
Never push to
main. Never merge. One branch,markup-preview, cut frommainafter 1.4.0 has merged. Task 4 is the version bump and waits for the user to merge it. -
File-system-synchronized groups. New
.swiftfiles undergitbay/andgitbayTests/are picked up automatically. Do not editproject.pbxprojexcept 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 thegbpalette or the system semantic styles. No new visual treatment: aDisclosureGrouprow in the existingForm/Listis 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?)ingitbay/Views/Shared/. -
Produces:
MarkupPreview(text: String, format: String?, identifier: String);static func MarkupPreview.format(forFile name: String) -> String?returning"org","md"ornil. -
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(MRCreateSheetBody 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(Notessection) - 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(newtestMarkupPreviewFlow()and itssetUpWithErrorguard)
Interfaces:
- Consumes:
Self.fixtureRepo,Self.fixtureIssueTitle,requireFixtureRepo(),ensureFixtureIssue(),openRepo(_:),scrollTo(_:swipes:); identifiersissue-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".