Commit 69ddde3892

69ddde3892c0b0997769b7951aba0d265ca85cf7

parent: 96b37d3fa7

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-22 17:40 UTC

docs: plan for 1.5.0, markup previews

Layout: unified · split

docs/superpowers/plans/2026-09-22-1.5.0-markup-previews.md added +433
@@ -0,0 +1,433 @@
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".