# 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.**
```bash
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**
```bash
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`:
```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`:
```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 0`.
- [ ] **Step 6: Commit**
```bash
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:
```swift
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:
```swift
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
```swift
TextField("Comment", text: $commentText, axis: .vertical)
.lineLimit(1...5)
```
to
```swift
TextField("Comment", text: $commentText, axis: .vertical)
.lineLimit(1...5)
.accessibilityIdentifier("issue-comment-field")
```
and immediately after the closing brace of that `HStack`, add:
```swift
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`:
```swift
TextField("Comment", text: $commentText, axis: .vertical)
.lineLimit(1...5)
.accessibilityIdentifier("mr-comment-field")
```
```swift
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
```swift
Section("Notes") {
TextEditor(text: $notes)
.frame(minHeight: 140)
.autocorrectionDisabled()
}
```
with
```swift
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:
```swift
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 0`. The call sites have no unit tests; Task 3's live step drives one end to end.
- [ ] **Step 8: Check the design greps**
```bash
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**
```bash
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:
```swift
if name.contains("testMarkupPreviewFlow") {
try requireFixtureRepo()
ensureFixtureIssue()
}
```
- [ ] **Step 2: Add the test**
Add after the last test in the class:
```swift
/// 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**
```bash
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`).
```bash
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**
```bash
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**
```bash
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:
```bash
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**
```bash
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".