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

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

433 lines · 18518 bytes

  1# 1.5.0 Markup Previews Implementation Plan
  2
  3> **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.
  4
  5**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.
  6
  7**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.
  8
  9**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`.
 10
 11**Spec:** `docs/superpowers/specs/2026-09-22-parity-followup-design.md` (section "1.5.0: markup previews")
 12
 13## Global Constraints
 14
 15- **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.
 16- **Commits are signed.** `commit.gpgsign` is on; do not pass `--no-gpg-sign`.
 17- **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.
 18- **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.
 19- **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.
 20- **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.
 21- Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary.
 22- **Run the whole unit target, and read the count.**
 23
 24  ```bash
 25  cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \
 26    -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \
 27    -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20
 28  xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \
 29    | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])"
 30  ```
 31
 32  "Run the unit target" below means these two commands. Record the count before Task 1 as `BASE`.
 33
 34---
 35
 36### Task 1: `MarkupPreview`
 37
 38**Files:**
 39- Create: `gitbay/Views/Shared/MarkupPreview.swift`
 40- Create: `gitbayTests/MarkupPreviewTests.swift`
 41
 42**Interfaces:**
 43- Consumes: `FormattedBodyView(text: String, format: String?)` in `gitbay/Views/Shared/`.
 44- Produces: `MarkupPreview(text: String, format: String?, identifier: String)`; `static func MarkupPreview.format(forFile name: String) -> String?` returning `"org"`, `"md"` or `nil`.
 45
 46- [ ] **Step 1: Branch**
 47
 48```bash
 49cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c markup-preview
 50```
 51
 52Run the unit target and record the count as `BASE`.
 53
 54- [ ] **Step 2: Write the failing tests**
 55
 56Create `gitbayTests/MarkupPreviewTests.swift`:
 57
 58```swift
 59import Testing
 60@testable import gitbay
 61
 62struct MarkupPreviewFormatTests {
 63
 64    @Test func orgFilesRenderAsOrg() {
 65        #expect(MarkupPreview.format(forFile: "README.org") == "org")
 66        #expect(MarkupPreview.format(forFile: "profile/README.org") == "org")
 67    }
 68
 69    @Test func markdownFilesRenderAsMarkdown() {
 70        #expect(MarkupPreview.format(forFile: "README.md") == "md")
 71        #expect(MarkupPreview.format(forFile: "notes.markdown") == "md")
 72        #expect(MarkupPreview.format(forFile: "CHANGES.MD") == "md")
 73    }
 74
 75    /// Source files show as source, so there is nothing to preview.
 76    @Test func otherFilesHaveNoPreview() {
 77        #expect(MarkupPreview.format(forFile: "main.swift") == nil)
 78        #expect(MarkupPreview.format(forFile: "Makefile") == nil)
 79        #expect(MarkupPreview.format(forFile: "notes.txt") == nil)
 80    }
 81}
 82```
 83
 84- [ ] **Step 3: Run the unit target to verify it fails**
 85
 86Expected: build FAILS with `cannot find 'MarkupPreview' in scope`.
 87
 88- [ ] **Step 4: Create the view**
 89
 90Create `gitbay/Views/Shared/MarkupPreview.swift`:
 91
 92```swift
 93import SwiftUI
 94
 95/// A collapsed "Preview" row under a markup editor. Expanded, it renders
 96/// the draft with `FormattedBodyView`, the view the app's threads use, so
 97/// what it shows is what the app will show once the text is sent. It sits
 98/// under the editor rather than replacing it, so the draft stays in view.
 99struct MarkupPreview: View {
100
101    let text: String
102    let format: String?
103    let identifier: String
104
105    @State private var expanded = false
106
107    var body: some View {
108        DisclosureGroup("Preview", isExpanded: $expanded) {
109            if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
110                Text("Nothing to preview.")
111                    .font(.gbSans(.subheadline))
112                    .foregroundStyle(.secondary)
113            } else {
114                FormattedBodyView(text: text, format: format)
115                    .padding(.vertical, 4)
116            }
117        }
118        .font(.gbSans(.subheadline))
119        .accessibilityIdentifier(identifier)
120    }
121
122    /// The format a file's contents render in, or nil for a file shown as
123    /// source. The same extensions the forge renders a README or wiki
124    /// page from.
125    static func format(forFile name: String) -> String? {
126        switch (name as NSString).pathExtension.lowercased() {
127        case "org": "org"
128        case "md", "markdown": "md"
129        default: nil
130        }
131    }
132}
133```
134
135- [ ] **Step 5: Run the unit target to verify it passes**
136
137Expected: `Passed <BASE + 3> 0`.
138
139- [ ] **Step 6: Commit**
140
141```bash
142cd /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"
143```
144
145---
146
147### Task 2: Put it under every markup editor
148
149**Files:**
150- Modify: `gitbay/Views/Shared/ComposeSheet.swift:63-75` (Body section)
151- Modify: `gitbay/Views/MRs/MRListView.swift:114-125` (`MRCreateSheet` Body section)
152- Modify: `gitbay/Views/Issues/IssueView.swift:203-226` (comment row)
153- Modify: `gitbay/Views/MRs/MRView.swift:538-561` (comment row)
154- Modify: `gitbay/Views/Releases/ReleaseListView.swift:106-110` (`Notes` section)
155- Modify: `gitbay/Views/Repos/FileEditSheet.swift:18-28` (content section)
156
157**Interfaces:**
158- Consumes: `MarkupPreview(text:format:identifier:)`, `MarkupPreview.format(forFile:)` (Task 1); `FileViewModel.fileName: String`.
159- 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`.
160
161Line numbers are as of `main` after 1.4.0; find each block by its content.
162
163- [ ] **Step 1: Issue and MR create/edit, release edit**
164
165In `gitbay/Views/Shared/ComposeSheet.swift`, in the `Section("Body")`, after the `if showsFormat { Picker("Format", …) … }` block, add:
166
167```swift
168                    MarkupPreview(text: bodyText, format: format, identifier: "compose-preview")
169```
170
171`format` is the constant `"md"` where the sheet shows no picker, which is what the release editor sends.
172
173- [ ] **Step 2: MR create**
174
175In `gitbay/Views/MRs/MRListView.swift`, in `MRCreateSheet`'s `Section("Body")`, after the `Picker("Format", selection: $format) { … }` block with its modifiers, add:
176
177```swift
178                    MarkupPreview(text: bodyText, format: format, identifier: "mr-create-preview")
179```
180
181- [ ] **Step 3: Issue comments**
182
183In `gitbay/Views/Issues/IssueView.swift`, in the comment `HStack`, give the field an identifier: change
184
185```swift
186                TextField("Comment", text: $commentText, axis: .vertical)
187                    .lineLimit(1...5)
188```
189
190to
191
192```swift
193                TextField("Comment", text: $commentText, axis: .vertical)
194                    .lineLimit(1...5)
195                    .accessibilityIdentifier("issue-comment-field")
196```
197
198and immediately after the closing brace of that `HStack`, add:
199
200```swift
201            if !commentText.isEmpty {
202                MarkupPreview(text: commentText, format: commentFormat,
203                              identifier: "issue-comment-preview")
204            }
205```
206
207- [ ] **Step 4: MR comments**
208
209In `gitbay/Views/MRs/MRView.swift`, make the same two changes to the comment `HStack` with the identifiers `mr-comment-field` and `mr-comment-preview`:
210
211```swift
212                TextField("Comment", text: $commentText, axis: .vertical)
213                    .lineLimit(1...5)
214                    .accessibilityIdentifier("mr-comment-field")
215```
216
217```swift
218            if !commentText.isEmpty {
219                MarkupPreview(text: commentText, format: commentFormat,
220                              identifier: "mr-comment-preview")
221            }
222```
223
224- [ ] **Step 5: Release notes on create**
225
226In `gitbay/Views/Releases/ReleaseListView.swift`, replace
227
228```swift
229                Section("Notes") {
230                    TextEditor(text: $notes)
231                        .frame(minHeight: 140)
232                        .autocorrectionDisabled()
233                }
234```
235
236with
237
238```swift
239                Section("Notes") {
240                    TextEditor(text: $notes)
241                        .frame(minHeight: 140)
242                        .autocorrectionDisabled()
243                    // release create sends no --format, and release notes
244                    // render as markdown.
245                    MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview")
246                }
247```
248
249- [ ] **Step 6: Markup files in the file editor**
250
251In `gitbay/Views/Repos/FileEditSheet.swift`, after the `Section { TextEditor(text: $content) … } header: { … } footer: { … }` block and before `Section("Message")`, add:
252
253```swift
254                if let format = MarkupPreview.format(forFile: model.fileName) {
255                    Section {
256                        MarkupPreview(text: content, format: format, identifier: "file-edit-preview")
257                    }
258                }
259```
260
261- [ ] **Step 7: Build and run the unit target**
262
263Expected: `Passed <BASE + 3> 0`. The call sites have no unit tests; Task 3's live step drives one end to end.
264
265- [ ] **Step 8: Check the design greps**
266
267```bash
268cd /Users/cmc/git/krz/gitbay-ios && grep -rnE '\.(green|red|blue|orange|yellow|purple)\b' gitbay/ --include=*.swift | grep -v gb; \
269grep -rn '\.font(\.' gitbay/Views/Shared/MarkupPreview.swift | grep -vE 'gbSans|gbMono'
270```
271
272Expected: no output from either.
273
274- [ ] **Step 9: Commit**
275
276```bash
277cd /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
278
279Issue and MR bodies on create and edit, their comments, release notes,
280and a .md, .markdown or .org file in the file editor."
281```
282
283---
284
285### Task 3: Live step, then the MR
286
287**Files:**
288- Modify: `gitbayUITests/LiveSmokeUITests.swift` (new `testMarkupPreviewFlow()` and its `setUpWithError` guard)
289
290**Interfaces:**
291- Consumes: `Self.fixtureRepo`, `Self.fixtureIssueTitle`, `requireFixtureRepo()`, `ensureFixtureIssue()`, `openRepo(_:)`, `scrollTo(_:swipes:)`; identifiers `issue-comment-field`, `issue-comment-preview` (Task 2).
292
293The test types a comment and never sends it.
294
295- [ ] **Step 1: Add the fixture guard**
296
297In `setUpWithError()`, after the last `if name.contains(…)` block, add:
298
299```swift
300        if name.contains("testMarkupPreviewFlow") {
301            try requireFixtureRepo()
302            ensureFixtureIssue()
303        }
304```
305
306- [ ] **Step 2: Add the test**
307
308Add after the last test in the class:
309
310```swift
311    /// A comment's preview renders its markup before anything is sent:
312    /// the bold markers are gone and the words remain. Nothing is posted.
313    func testMarkupPreviewFlow() throws {
314        openRepo(Self.fixtureRepo)
315        app.staticTexts["Issues"].firstMatch.tap()
316        let issue = app.staticTexts[Self.fixtureIssueTitle].firstMatch
317        XCTAssertTrue(issue.waitForExistence(timeout: 15), "fixture issue not listed")
318        issue.tap()
319
320        let field = app.descendants(matching: .any)
321            .matching(identifier: "issue-comment-field").firstMatch
322        XCTAssertTrue(scrollTo(field, swipes: 10), "no comment field on the issue")
323        field.tap()
324        let draft = "**ui-smoke preview**"
325        field.typeText(draft)
326
327        let preview = app.descendants(matching: .any)
328            .matching(identifier: "issue-comment-preview").firstMatch
329        XCTAssertTrue(scrollTo(preview), "no preview row for a non-empty comment")
330        preview.tap()
331        XCTAssertTrue(app.staticTexts
332            .containing(NSPredicate(format: "label CONTAINS 'ui-smoke preview'")).firstMatch
333            .waitForExistence(timeout: 5), "preview did not render the draft")
334        XCTAssertFalse(app.staticTexts
335            .containing(NSPredicate(format: "label CONTAINS '**ui-smoke'")).firstMatch.exists,
336            "preview shows the raw markup")
337
338        // Leave the fixture as found: clear the draft, send nothing.
339        XCTAssertTrue(scrollTo(field), "comment field scrolled away")
340        field.tap()
341        field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: draft.count))
342        XCTAssertFalse(preview.exists, "preview row outlived an empty comment")
343    }
344```
345
346- [ ] **Step 3: Build the UI test target**
347
348```bash
349cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \
350  -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' build-for-testing 2>&1 | grep -E "error:|TEST BUILD (SUCCEEDED|FAILED)" | tail -5
351```
352
353Expected: `** TEST BUILD SUCCEEDED **`.
354
355- [ ] **Step 4: Run the live test (needs a token from the user)**
356
357Ask 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`).
358
359```bash
360cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \
361  xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \
362  -only-testing:gitbayUITests/LiveSmokeUITests/testMarkupPreviewFlow \
363  -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10
364```
365
366Expected: `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.
367
368- [ ] **Step 5: Look at one preview in the simulator**
369
370Launch 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.
371
372- [ ] **Step 6: Commit, push, open the MR**
373
374```bash
375cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: a comment preview renders its markup" && \
376git push -u origin markup-preview && gitbay mr create --source markup-preview --target main \
377  --title "Preview markup before sending" --file - <<'EOF'
378A collapsed Preview row under every markup editor: issue and MR create
379and edit, their comments, release create and edit, and a .md,
380.markdown or .org file in the file editor. It renders the draft with
381FormattedBodyView, the view the app's threads use, so it shows what the
382app will show. Like the threads, it resolves no autolinks; the web's
383preview does.
384
385Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md
386EOF
387```
388
389---
390
391### Task 4: Bump to 1.5.0 (14)
392
393Starts only after the user has merged the MR from Task 3.
394
395**Files:**
396- Modify: `gitbay.xcodeproj/project.pbxproj` (the four version lines)
397
398- [ ] **Step 1: Branch, bump, verify**
399
400```bash
401cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c bump-1.5.0 && \
402sed -i '' -e 's/MARKETING_VERSION = 1.4.0;/MARKETING_VERSION = 1.5.0;/' \
403  -e 's/CURRENT_PROJECT_VERSION = 13;/CURRENT_PROJECT_VERSION = 14;/' gitbay.xcodeproj/project.pbxproj && \
404git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj
405```
406
407Expected: `1 file changed, 4 insertions(+), 4 deletions(-)` and `OK`.
408
409- [ ] **Step 2: Both runtimes and a Release device build**
410
411Run the unit target on iPhone 17 Pro (26.5), then:
412
413```bash
414cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \
415  -destination 'id=9F8E5D84-0A15-4C64-8A45-994667A73817' -only-testing:gitbayTests test 2>&1 | grep -E "TEST (SUCCEEDED|FAILED)"
416xcodebuild build -scheme gitbay -configuration Release -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO 2>&1 \
417  | grep -E "error:|While running pass|BUILD (SUCCEEDED|FAILED)" | tail -5
418```
419
420Expected: `** TEST SUCCEEDED **` and `** BUILD SUCCEEDED **`.
421
422- [ ] **Step 3: Commit, push, open the MR**
423
424```bash
425cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.5.0 (14)" && \
426git 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."
427```
428
429Tagging `v1.5.0`, archiving and uploading are the user's.
430
431- [ ] **Step 4: Flip the Parity rows upstream**
432
433Once 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".