asterism branch worktree-settings-cleanup commits 2 files 10 touched lines +385 / -82

Pre-push review: settings-cleanup (T-2117)

Restructure of the Settings screen: Sites first, one Backup section, and the diagnostic surfaces behind a collapsed Debug disclosure. Smolspec-driven chore; four parallel review agents, five fixes applied, all verified.

At a glance

  • Settings order is now Sites → Backup (export + import + interrupted-import notice) → collapsed Debug (sync rows + Check Library).
  • All 22 pre-existing accessibility identifiers survive verbatim; the only addition is settings-debug-disclosure, placed on the label per the measured EntryDetailView gotcha.
  • The sync load path is untouched — .task(id:) stays on the List, so counts keep loading while Debug is collapsed.
  • UI tests gained a shared expandDisclosure core; the collapsed-by-default MUST is now asserted, closing the one coverage gap the spec review found.
  • Known trade-off (Q4): a banner-worthy sync failure is invisible while Debug is collapsed; backup export failures still surface in the always-visible Backup section.

Verdict

Ready to push

No major findings across the four review dimensions. All five actionable findings (two minors from quality/reuse, one coverage minor from spec adherence, two nits) were fixed in a follow-up commit; four nits were deliberately skipped with reasons. Verified by make test-core, a clean forced-recompile build, and five targeted UI-test runs covering both the new layout and the refactored shared helper.

Review findings

9 raised · 5 fixed · 4 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

The Settings screen was reorganised. It used to open with technical iCloud sync read-outs; now it opens with Sites (the websites the app knows how to read), followed by one Backup section holding export and import, and everything diagnostic — sync status and the Check Library button — sits behind a "Debug" row at the bottom that starts closed.

Why It Matters

The things a person opens Settings for come first, and the technical read-outs no longer crowd the screen. Nothing was removed: tapping Debug shows exactly what was there before.

Key Concepts

Disclosure group: a row with a chevron that expands to reveal more rows, like a folder. Accessibility identifier: an invisible name on a screen element so tests and assistive tech can find it; all existing names were kept.

Changes Overview

SettingsView.swift: the List is reordered to sitesSection → a combined "Backup" Section (interrupted-import notice, backupRow, import view when present) → debugSection. The old iCloud Section became syncRows and moved inside a DisclosureGroup with diagnosticsRow, guarded by syncModel != nil || diagnosticsModel != nil.

Implementation Approach

The Debug section follows the captureDetailsSection precedent in EntryDetailView: a headerless Section whose disclosure label is the section's face, with the identifier on the label Text — an identifier on the group masks every identifier inside it (measured). showingDebug defaults to false on every open (Q5). The sync load stays keyed off the List's .task(id: syncModel?.status), so collapsing neither cancels nor restarts it.

Trade-offs

Sync failures hide while Debug is collapsed (Q4, accepted per the ticket; export failures still surface in Backup). Expanded state is deliberately not persisted. Section order is not UI-test asserted — frame comparisons are brittle in XCUITest.

Technical Deep Dive

The restructure is identity-stable: both operands of the Debug guard are fixed for the view's lifetime (diagnosticsModel is a let, syncModel assigned once in init), so the branch can never flip at runtime — no structural churn, no lost disclosure state. Collapsed, the DisclosureGroup builds no content, so the screen renders fewer rows on open than the old always-rendered iCloud section.

Architecture Impact

View layer only; no model, wording, or initialiser changes, so ContentView needed no edits. The shared expandDisclosure core keeps the one behavioural asymmetry explicit — entry detail scrolls with a drag from below its TextEditor (which swallows swipeUp), Settings passes plain swipeUp — and gives the next collapsed section a two-line wrapper.

Potential Issues

The collapsed-by-default assertion could pass vacuously if a future scenario rendered Settings with the disclosure expanded but below the fold (lazy-List rows off-screen are out of the tree); the subsequent expand-and-witness step still catches a moved-out-of-disclosure regression. The staticTexts["Debug"] label fallback could tap a stray "Debug" text; the witness contract turns that into a retry, not a false pass.

Important changes — detailed

SettingsView: reorder to Sites → Backup → collapsed Debug

Asterism/Asterism/Views/SettingsView.swift

Why it matters. The user-visible change: reader-facing controls lead, diagnostics collapse. Every moved row keeps its identifier and model-sourced wording.

What to look at. SettingsView.swift body (List reorder) and debugSection

Takeaway. A pure section move can be verified by identifier diffing: all 22 pre-existing accessibility identifiers survive verbatim, so assistive tech and every untouched UI suite keep working.
Rationale. T-2117 names the exact order; the interrupted-import notice goes with Backup because importing again is its repair (Q1).

Debug disclosure: identifier on the label, not the group

Asterism/Asterism/Views/SettingsView.swift

Why it matters. Correctness of the entire test surface inside the group depends on this placement.

What to look at. debugSection — DisclosureGroup label carries settings-debug-disclosure

Takeaway. An accessibility identifier on a DisclosureGroup is applied outside its content and overrides every identifier inside it — measured during the entry-detail work, reused here.
Rationale. Follows the captureDetailsSection precedent in EntryDetailView (Q3); a section header plus a disclosure label would name the section twice, so the section is headerless.

Sync load path untouched: .task(id:) stays on the List

Asterism/Asterism/Views/SettingsView.swift

Why it matters. The counts and health line must keep loading while the section is collapsed; moving the task into the disclosure would tie loading to expansion.

What to look at. .task(id: syncModel?.status) on the List; syncModel remains @State-owned

Takeaway. Keying a task off observed model state at the container level decouples data loading from the visibility of the rows that display it.
Rationale. Verified by the efficiency review: toggling the disclosure is a plain @State change that neither cancels nor restarts the task, and the @State-owned model keeps its loaded counts.

UIJourneySupport: shared expandDisclosure core

Asterism/AsterismUITests/UIJourneySupport.swift

Why it matters. Two review agents independently flagged the new helper as a near-verbatim copy of expandEntryCaptureDetails inside the file whose charter is deduplication.

What to look at. private expandDisclosure(headerIdentifier:labelText:witness:in:scroll:...) with two thin wrappers

Takeaway. The witness-row contract — only the witness's arrival proves the group opened — implicitly tests the identifier-on-label rule: a masked identifier means the witness never arrives.
Rationale. Review fix. The scroll strategy stays a parameter because entry detail must drag from below its TextEditor while Settings can swipeUp.

AccessibilityJourneyUITests: collapsed-by-default is asserted

Asterism/AsterismUITests/AccessibilityJourneyUITests.swift

Why it matters. The spec review found this was the one MUST with zero coverage — a regression shipping Debug expanded would have passed every suite silently.

What to look at. testSettingsSyncSectionIsReachableAndReportsBothDirections — XCTAssertFalse on the sync row after settings-view renders, before expanding

Takeaway. Expansion helpers that return early when the witness already exists tolerate an already-expanded state; pinning "starts collapsed" needs an explicit negative assertion before the helper runs.
Rationale. Review fix closing the coverage gap on the smolspec's collapsed-by-default MUST.

Key decisions

Q1 — Interrupted-import notice lives in the Backup section.

Its only repair is importing the backup again, which is that section's control.

Q2 — The combined section is titled "Backup".

Both controls act on backups; "Data" no longer fits once Check Library leaves.

Q3 — Headerless Section + DisclosureGroup labelled "Debug".

Follows the captureDetailsSection precedent; a section header plus a disclosure label would name the section twice, and an identifier on the group masks every identifier inside it.

Q4 — Sync failures hide while Debug is collapsed.

T-2117 explicitly moves all iCloud sync information into the collapsed section; backup export failures (including the torn-groups Check Library route) still surface in the always-visible Backup section.

Q5 — Debug expanded state is not persisted across launches.

Debug is an occasional surface; defaulting collapsed every launch is the point of the change.

showingDebug stays an explicit @State binding.

Flagged as redundant (a binding-less DisclosureGroup also defaults collapsed), but it mirrors showingCaptureDetails in EntryDetailView — the house pattern. Changing it would mean changing both places or neither; left as is.

Review findings

SeverityAreaFindingResolution
minorUIJourneySupport.swiftexpandSettingsDebug duplicated the expandEntryCaptureDetails algorithm line-for-line inside the file whose charter is deduplication (flagged independently by the reuse and quality reviews).Extracted a private expandDisclosure core; both helpers are now thin wrappers keeping their own doc history and scroll strategies.
minorSettingsView.swift commentThe List-modifier comment still referenced the deleted iCloud Section and 'the section's own rows'.Reworded to describe the sync rows now inside the Debug disclosure while keeping the underlying lesson.
minorTest coverage"Collapsed by default" — a spec MUST — was asserted nowhere; expandSettingsDebug returns early if the witness already exists, so an expanded-by-default regression would pass silently.Added an explicit XCTAssertFalse on the sync row after Settings renders, before expanding.
nitsmolspec.mdThe Q2 citation sat on the Debug-last clause; Q2 is the Backup-title decision.Moved the citation to the title it cites.
nitdocs/asterism-design.mdThe M4b milestone summary described the sync lines as if still top-level in Settings.Added a parenthetical noting they now live behind the collapsed Debug disclosure.
nitAccessibilityJourneyUITests.swiftThe waitForExistence after expandSettingsDebug re-checks what the helper just proved.Left in place — it carries the Req 8.1 assertion message and documents the requirement.
nitSettingsView.swiftshowingDebug is written nowhere and only read as the isExpanded binding; a binding-less DisclosureGroup would behave identically.Skipped — mirrors showingCaptureDetails in EntryDetailView (house pattern); fixing means changing both places or neither.
nitUIJourneySupport.swiftThe candidates array rebuilds three XCUIElement queries per loop iteration, and the staticTexts label fallback could match unrelated text.Skipped — test-only cost, mirrors the established pattern, and the witness contract turns a stray-text tap into a retry rather than a false pass.
nitSettingsSyncModel.swift (outside diff)load() awaits diagnostics then duplicateWorkload sequentially and counts quarantines by building the full map.Skipped — pre-existing, both reads hop to the same actor so async let buys nothing real; recorded for completeness.

Per-file diffs

Click to expand.

Asterism/Asterism/Views/SettingsView.swift Modified +78 / -50
diff --git a/Asterism/Asterism/Views/SettingsView.swift b/Asterism/Asterism/Views/SettingsView.swiftindex bc2e923..283aa81 100644--- a/Asterism/Asterism/Views/SettingsView.swift+++ b/Asterism/Asterism/Views/SettingsView.swift@@ -7,6 +7,9 @@ struct SettingsView: View {     @State private var model: SettingsBackupModel     @State private var importModel: SettingsBackupImportModel?     @State private var showingShareSheet = false+    /// Collapsed on every open (T-2117, Q5): Debug is an occasional surface,+    /// and defaulting closed is the point of the section.+    @State private var showingDebug = false      /// Req 4.2's second route to the diagnosis screen. The screen reads the     /// diagnoses in its own `task`, so holding the model from here does not pin@@ -56,36 +59,31 @@ struct SettingsView: View {      var body: some View {         List {-            // Above Data: sync is the state the reader is most likely to be-            // checking, and the Data actions below read differently once it is-            // known whether anything is reaching iCloud at all.-            syncSection+            // Sites leads (T-2117): site management is the reader-facing+            // control; the sync status that used to sit here is debug-tier now+            // and lives in the collapsed section at the bottom.+            sitesSection              Section {                 interruptedImportRow                 backupRow-                diagnosticsRow-            } header: {-                ConstellationSectionHeader("Data", accent: .violet)-            }--            sitesSection--            if let importModel {-                Section {+                if let importModel {                     SettingsBackupImportView(model: importModel)-                } header: {-                    ConstellationSectionHeader("Import", accent: .violet)                 }+            } header: {+                ConstellationSectionHeader("Backup", accent: .violet)             }++            debugSection         }         .navigationTitle("Settings")         .accessibilityIdentifier("settings-view")-        // Attached to the List, not to the iCloud `Section`. With `.task` and an-        // accessibility identifier on the Section itself, none of its rows-        // appeared in the accessibility tree at all — the UI test could not find-        // a single one. Modifiers on a top-level Section are not worth the risk;-        // the section's own rows carry the identifiers.+        // Attached to the List, not to any Section. When the sync rows had a+        // top-level Section of their own, giving that Section `.task` and an+        // accessibility identifier removed every one of its rows from the+        // accessibility tree — the UI test could not find a single one.+        // Modifiers on a top-level Section are not worth the risk; the rows+        // themselves (now inside the Debug disclosure) carry the identifiers.         // Keyed on the observed status, not a bare `.task`. Settings can stay         // open across an arrival, and the counts Req 8.5 puts beside the sync         // lines would otherwise describe the library as it was when the sheet@@ -106,50 +104,72 @@ struct SettingsView: View {         }     } -    // MARK: - iCloud Section (Req 8.1–8.5)+    // MARK: - Debug Section (T-2117)++    /// The diagnostic surfaces — sync status and Check Library — behind one+    /// collapsed disclosure at the end of the screen. Follows+    /// `captureDetailsSection` in `EntryDetailView`: the rows are the+    /// disclosure's own content so they keep their identifiers and hit+    /// targets, and **the identifier goes on the label, not the group** — an+    /// identifier on a `DisclosureGroup` is applied outside its content and+    /// overrides every identifier inside it.+    @ViewBuilder+    private var debugSection: some View {+        if syncModel != nil || diagnosticsModel != nil {+            Section {+                DisclosureGroup(isExpanded: $showingDebug) {+                    syncRows+                    diagnosticsRow+                } label: {+                    Text("Debug")+                        .font(.subheadline)+                        .foregroundStyle(AsterismColors.primaryText)+                        .accessibilityIdentifier("settings-debug-disclosure")+                }+            }+        }+    }++    // MARK: - iCloud Sync Rows (Req 8.1–8.5)      /// Every sentence here comes from the model (the `LibraryDiagnosticsModel`     /// precedent): this view chooses rows and styling, never wording.     @ViewBuilder-    private var syncSection: some View {+    private var syncRows: some View {         if let syncModel {-            Section {-                Text(syncModel.lastExportLine)-                    .font(.callout)-                    .accessibilityIdentifier("settings-sync-export-line")-                Text(syncModel.lastImportLine)-                    .font(.callout)-                    .accessibilityIdentifier("settings-sync-import-line")-                Text(syncModel.observationNote)+            Text(syncModel.lastExportLine)+                .font(.callout)+                .accessibilityIdentifier("settings-sync-export-line")+            Text(syncModel.lastImportLine)+                .font(.callout)+                .accessibilityIdentifier("settings-sync-import-line")+            Text(syncModel.observationNote)+                .font(.caption)+                .foregroundStyle(.secondary)+                .accessibilityIdentifier("settings-sync-observation-note")++            if let firstSync = syncModel.firstSyncLine {+                Text(firstSync)                     .font(.caption)                     .foregroundStyle(.secondary)-                    .accessibilityIdentifier("settings-sync-observation-note")--                if let firstSync = syncModel.firstSyncLine {-                    Text(firstSync)-                        .font(.caption)-                        .foregroundStyle(.secondary)-                        .accessibilityIdentifier("settings-sync-first-sync-line")-                }+                    .accessibilityIdentifier("settings-sync-first-sync-line")+            } -                if let failure = syncModel.failure {-                    syncFailureRow(failure)-                }+            if let failure = syncModel.failure {+                syncFailureRow(failure)+            } -                VStack(alignment: .leading, spacing: 4) {-                    Text(syncModel.healthLine)-                        .font(.callout)-                        .accessibilityIdentifier("settings-sync-health-line")-                    // Req 8.5: the counts are beside the sync lines whether or-                    // not they are zero, so "healthy" is never the only thing-                    // the reader has to go on.-                    Text(syncModel.countsLine)-                        .font(.caption)-                        .foregroundStyle(.secondary)-                        .accessibilityIdentifier("settings-sync-counts-line")-                }-            } header: {-                ConstellationSectionHeader("iCloud")+            VStack(alignment: .leading, spacing: 4) {+                Text(syncModel.healthLine)+                    .font(.callout)+                    .accessibilityIdentifier("settings-sync-health-line")+                // Req 8.5: the counts are beside the sync lines whether or+                // not they are zero, so "healthy" is never the only thing+                // the reader has to go on.+                Text(syncModel.countsLine)+                    .font(.caption)+                    .foregroundStyle(.secondary)+                    .accessibilityIdentifier("settings-sync-counts-line")             }         }     }@@ -180,8 +200,8 @@ struct SettingsView: View {      // MARK: - Interrupted import (Req 4.4) -    /// Above the backup and Check Library rows, and in the section whose Import-    /// control is the repair. Shown however old the sidecar is — an import that+    /// Above the export and import rows, in the section whose Import control+    /// is the repair (Q1). Shown however old the sidecar is — an import that     /// stopped partway did not become complete by being ignored for a month.     @ViewBuilder     private var interruptedImportRow: some View {@@ -201,8 +221,8 @@ struct SettingsView: View {      // MARK: - Sites (Req 6.1) -    /// After Data: sync and the backup actions are what a reader opens Settings-    /// for; the site list is where they go when a *particular* site is wrong.+    /// First on the screen (T-2117): site management is what a reader opens+    /// Settings for; backup sits below it and the diagnostics behind Debug.     @ViewBuilder     private var sitesSection: some View {         if let sitesModel, let siteDetailModel {
Asterism/AsterismUITests/UIJourneySupport.swift Modified +66 / -25
diff --git a/Asterism/AsterismUITests/UIJourneySupport.swift b/Asterism/AsterismUITests/UIJourneySupport.swiftindex f80b7a4..8e03273 100644--- a/Asterism/AsterismUITests/UIJourneySupport.swift+++ b/Asterism/AsterismUITests/UIJourneySupport.swift@@ -89,42 +89,72 @@ extension XCTestCase {                 thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.25)))     } -    /// Opens entry detail's "Capture details" disclosure.-    ///-    /// Q49 moved the title-recovery actions and the capture's site behind it,-    /// collapsed, at the bottom of the screen — so a journey that wants either-    /// has to open it first, and has to scroll past the note editor to reach it.-    /// (Q52 then took the provenance evidence out of the group altogether, and-    /// Q54 moved the capture date up under the note.)+    /// Shared core for the collapsed-disclosure openers below.     ///     /// SwiftUI surfaces a `DisclosureGroup` header as a button or as its label-    /// text depending on composition, which is why this tries both.-    func expandEntryCaptureDetails(-        in app: XCUIApplication, file: StaticString = #filePath, line: UInt = #line+    /// text depending on composition, which is why this tries both. Only the+    /// witness row's arrival says the group actually opened — a tap that landed+    /// on the header's label rather than the row would otherwise pass silently.+    /// Each scroll pass covers the header still being below the fold, or the+    /// group having opened onto rows that are.+    private func expandDisclosure(+        headerIdentifier: String, labelText: String, witness: XCUIElement,+        in app: XCUIApplication, scroll: (XCUIApplication) -> Void,+        _ failureMessage: String, file: StaticString, line: UInt     ) {-        // The site line is the disclosure's first row, so its arrival is what-        // says the group actually opened — a tap that landed on the header's-        // label rather than the row would otherwise pass silently.-        let witness = app.anyElement("entry-detail-hostname")         for _ in 0..<8 {             if witness.exists { return }             let candidates = [-                app.buttons["entry-detail-capture-details"],-                app.staticTexts["Capture details"],-                app.anyElement("entry-detail-capture-details"),+                app.buttons[headerIdentifier],+                app.staticTexts[labelText],+                app.anyElement(headerIdentifier),             ]             if let header = candidates.first(where: { $0.exists && $0.isHittable }) {                 header.tap()                 if witness.waitForExistence(timeout: 3) { return }             }-            // Either the header is still below the fold, or it opened onto rows-            // that are.-            scrollEntryDetail(in: app)+            scroll(app)         }-        XCTFail(+        XCTFail(failureMessage, file: file, line: line)+    }++    /// Opens entry detail's "Capture details" disclosure.+    ///+    /// Q49 moved the title-recovery actions and the capture's site behind it,+    /// collapsed, at the bottom of the screen — so a journey that wants either+    /// has to open it first, and has to scroll past the note editor to reach it.+    /// (Q52 then took the provenance evidence out of the group altogether, and+    /// Q54 moved the capture date up under the note.) The site line is the+    /// disclosure's first row, so it is the witness.+    func expandEntryCaptureDetails(+        in app: XCUIApplication, file: StaticString = #filePath, line: UInt = #line+    ) {+        expandDisclosure(+            headerIdentifier: "entry-detail-capture-details", labelText: "Capture details",+            witness: app.anyElement("entry-detail-hostname"), in: app,+            scroll: { self.scrollEntryDetail(in: $0) },             "Entry detail should offer the Capture details disclosure", file: file, line: line)     } +    /// Opens Settings' "Debug" disclosure.+    ///+    /// T-2117 moved the iCloud sync rows and Check Library behind it, collapsed,+    /// at the bottom of the screen — so a journey that wants either has to open+    /// it first. The caller names the witness row, because the group's first row+    /// depends on which models the launch provided. Settings is a plain `List`,+    /// so `swipeUp` scrolls it (no embedded editor to swallow the swipe, unlike+    /// entry detail).+    func expandSettingsDebug(+        witness witnessIdentifier: String, in app: XCUIApplication,+        file: StaticString = #filePath, line: UInt = #line+    ) {+        expandDisclosure(+            headerIdentifier: "settings-debug-disclosure", labelText: "Debug",+            witness: app.anyElement(witnessIdentifier), in: app,+            scroll: { $0.swipeUp() },+            "Settings should offer the Debug disclosure", file: file, line: line)+    }+     /// Scrolls until the element exists, and returns whether it ever did.     ///     /// The same lazy-`List` fact `scrollUntilTappableAndTap` records: a row
Asterism/AsterismUITests/AccessibilityJourneyUITests.swift Modified +12 / -2
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftindex 1f3b3e5..7ae6637 100644--- a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -132,8 +132,17 @@ final class AccessibilityJourneyUITests: XCTestCase {         XCTAssertTrue(settingsButton.waitForExistence(timeout: 30))         settingsButton.tap() +        // T-2117 collapsed the sync rows behind the Debug disclosure. Collapsed+        // is asserted, not assumed: once Settings has rendered, no sync row may+        // be in the tree until the disclosure is opened.+        XCTAssertTrue(app.collectionViews["settings-view"].waitForExistence(timeout: 10))+        XCTAssertFalse(+            app.staticTexts["settings-sync-export-line"].exists,+            "The Debug section starts collapsed — no sync row before expanding")+        expandSettingsDebug(witness: "settings-sync-export-line", in: app)+         let exportLine = app.staticTexts["settings-sync-export-line"]-        XCTAssertTrue(exportLine.waitForExistence(timeout: 10), "The iCloud section should be present")+        XCTAssertTrue(exportLine.waitForExistence(timeout: 10), "The sync rows should be present")         let importLine = app.staticTexts["settings-sync-import-line"]         XCTAssertTrue(importLine.exists, "Req 8.1 reports the two directions separately")         XCTAssertNotEqual(exportLine.label, importLine.label)
Asterism/AsterismUITests/LibraryDiagnosticsUITests.swift Modified +2 / -0
diff --git a/Asterism/AsterismUITests/LibraryDiagnosticsUITests.swift b/Asterism/AsterismUITests/LibraryDiagnosticsUITests.swiftindex bba7af9..33e8cf1 100644--- a/Asterism/AsterismUITests/LibraryDiagnosticsUITests.swift+++ b/Asterism/AsterismUITests/LibraryDiagnosticsUITests.swift@@ -104,6 +104,8 @@ final class LibraryDiagnosticsUITests: XCTestCase {          app.buttons["settings-button"].tap()         require(any("settings-view"), "Settings opens")+        // T-2117 collapsed Check Library behind the Debug disclosure.+        expandSettingsDebug(witness: "settings-library-check-button", in: app)         scrollAndTap(app.buttons["settings-library-check-button"],                      "Settings carries the second route to the diagnosis screen") 
CHANGELOG.md + docs/asterism-design.md Modified +2 / -1
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 3fd3e5f..a716885 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -61,6 +61,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Changed +- The Settings screen is restructured around what a reader opens it for (T-2117, `specs/settings-cleanup/`). Sites now leads the screen; backup export and import share one "Backup" section, with the interrupted-import notice beside the Import control that repairs it; and the diagnostic surfaces — the iCloud sync status rows and Check Library — move behind a "Debug" disclosure at the bottom, collapsed by default. No wording or behaviour changed: the sync counts still load while the section is collapsed, and a blocked export still names its count and routes to Check Library from the Backup section itself. - The design document and style guide now say what shipped (M5 Documentation phase, `specs/polish-and-export/`): amber is documented as "actionable attention" rather than teaching-only, the standalone per-entry export's work line is specified with its omission cases, export's `firstCapturedAt` ordering is named where it was ambiguous, and the articles-mode exit is recorded in the M5 summary. - Backup export no longer refuses a library just because sync duplicated something in it (M4c phase 5, `specs/duplicate-reconciliation/`). Export used to refuse on *any* repeated record; it now projects a duplicated record into the archive as the one record it is, and refuses only where two copies genuinely disagree — naming how many, and pointing at Check Library, with a button to get there. If those copies are waiting on a duplicated work to be merged first, it says so. Once you have resolved the last disagreement the next export succeeds. Two copies of a taught rule export as one rule, keeping whichever copy the site actually teaches from. diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex 2374836..5f99b42 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -524,7 +524,7 @@ Tolerating an incoherent graph and reconciling one turn out to be separable, and Three graph states degrade instead of failing: an Entry or Work whose Site row is absent, more than one Site row per hostname, and two records of one type sharing an application UUID. Seventeen throw sites demote to recorded diagnoses; ambiguous identity lookups resolve to a deterministic winner; cited pattern ids resolve across all rows for a hostname, so provenance replay survives duplication; a diagnosis surface makes the state visible; and re-teaching can clear a diagnosis it fixes, which it currently cannot. No CloudKit, no schema change, no archive-format change. Worth having even if mirroring never ships: today one incoherent record locks the reader out of the whole library and simultaneously disables the backup export that would rescue it.  **M4b — CloudKit mirroring.** *Shipped. Spec: `specs/cloudkit-mirroring/`. Mirroring is on for **both** configurations, so a `Development` install is no longer device-local. Its two red budgets (that spec's Q55, T-2053) are **retired**: re-measured after M4c they are `capture-projection-duplicateSiteRows` at 76.3–79.1 ms against 100 ms and diagnosis refresh at 0.294–0.298 s against a 0.4 s regression ceiling, both green. The diagnosis-refresh improvement is M4c's doing — retiring the `.duplicateIdentity` diagnosis took the per-UUID bucketing off that path.*-Mirroring on the personal container; separate CloudKit containers for the personal and development configurations; sync visibility (actionable failures plus a last-successful-sync line in Settings); backup export that succeeds while the library is degraded; and an import that reconciles by application UUID in bounded batches instead of deleting everything and re-materialising.+Mirroring on the personal container; separate CloudKit containers for the personal and development configurations; sync visibility (actionable failures plus a last-successful-sync line in Settings — since T-2117 behind Settings' collapsed Debug disclosure); backup export that succeeds while the library is degraded; and an import that reconciles by application UUID in bounded batches instead of deleting everything and re-materialising.  Two constraints found while specifying M4a. **Only the app mirrors** — TN3164's *"Avoid synchronizing a store with multiple persistent containers"* names the app-and-extension-share-a-store case directly, each container keeps its own export history token (so both processes can export one object twice), and an extension is terminated too soon after completing its request for an asynchronous export to finish anyway. The extension writes locally and the app exports on next run. **The backup work is a format change, not a policy change:** export self-validates by decoding its own bytes, and that decode runs the reference validator; worse, duplicate Site rows cannot be represented at all, because `BackupV4Site` is keyed by hostname and every reference to a Site is a hostname string. Representing them means giving Site an identity in the payload. 
specs/settings-cleanup/ (smolspec, tasks, decision log, implementation) Added +225 / -0
diff --git a/specs/settings-cleanup/decision_log.md b/specs/settings-cleanup/decision_log.mdnew file mode 100644index 0000000..08d1150--- /dev/null+++ b/specs/settings-cleanup/decision_log.md@@ -0,0 +1,11 @@+# Decision Log: Settings Cleanup++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-12 | Interrupted-import notice lives in the combined Backup section | Its only repair is importing the backup again, which is that section's control |+| Q2 | 2026-08-12 | Combined import/export section is titled "Backup" | Both controls act on backups; "Data" no longer fits once Check Library leaves |+| Q3 | 2026-08-12 | Debug section is a headerless `Section` + `DisclosureGroup` labelled "Debug", identifier `settings-debug-disclosure` on the label | Follows the `captureDetailsSection` precedent in `EntryDetailView.swift`; a section header plus a disclosure label would name the section twice, and an identifier on the group masks every identifier inside it |+| Q4 | 2026-08-12 | Sync failures are hidden while Debug is collapsed | T-2117 explicitly moves all iCloud sync information into the collapsed section; backup export failures (including the torn-groups Check Library route) still surface in the always-visible Backup section |+| Q5 | 2026-08-12 | Debug expanded state is not persisted across launches | Debug is an occasional surface; defaulting collapsed every launch is the point of the change |diff --git a/specs/settings-cleanup/implementation.md b/specs/settings-cleanup/implementation.mdnew file mode 100644index 0000000..6347aed--- /dev/null+++ b/specs/settings-cleanup/implementation.md@@ -0,0 +1,134 @@+# Implementation Explanation: Settings Cleanup (T-2117)++Branch `worktree-settings-cleanup`, against `origin/main`. Spec:+`specs/settings-cleanup/smolspec.md`; decisions Q1–Q5 in+`specs/settings-cleanup/decision_log.md`.++## Beginner Level++### What Changed++The app's Settings screen was reorganised. Before, it opened with technical+iCloud sync status at the top, mixed backup and library-checking buttons in+one "Data" section, put Sites in the middle, and left the import controls at+the bottom. Now it opens with Sites (the list of websites the app knows how to+read), followed by one "Backup" section holding both the export and import+controls, and everything diagnostic — the sync status lines and the Check+Library button — sits behind a "Debug" row at the bottom that starts closed+and opens when tapped.++### Why It Matters++The things a person actually opens Settings for now come first, and the+technical read-outs no longer crowd the screen. Nothing was removed: tapping+Debug shows exactly the same sync information and library check as before.++### Key Concepts++- **Disclosure group**: a row with a chevron that expands to reveal more rows —+  like a folder you can open and close.+- **Accessibility identifier**: an invisible name attached to a screen element+  so automated tests (and assistive technologies) can find it regardless of its+  visible text. All existing names were kept, so everything that could find a+  row before can still find it.++## Intermediate Level++### Changes Overview++- `Asterism/Asterism/Views/SettingsView.swift`: `body`'s `List` reordered to+  `sitesSection` → a combined "Backup" `Section` (interrupted-import notice,+  `backupRow`, `SettingsBackupImportView` when an import model exists) →+  `debugSection`. The old `syncSection` became `syncRows` (same rows, no+  `Section` wrapper) and moved inside a `DisclosureGroup` together with+  `diagnosticsRow`, guarded by `syncModel != nil || diagnosticsModel != nil`.+- `Asterism/AsterismUITests/UIJourneySupport.swift`: a private+  `expandDisclosure(headerIdentifier:labelText:witness:in:scroll:...)` core,+  with `expandEntryCaptureDetails` and the new `expandSettingsDebug` as thin+  wrappers.+- `AccessibilityJourneyUITests` asserts the Debug section is collapsed on open+  (no sync row in the tree) before expanding; `LibraryDiagnosticsUITests`+  expands Debug before reaching Check Library.+- `CHANGELOG.md`, `docs/asterism-design.md` (one parenthetical), and the spec+  documents.++### Implementation Approach++The Debug section follows the `captureDetailsSection` precedent in+`EntryDetailView.swift`: a headerless `Section` whose `DisclosureGroup` label+is the section's face, with the accessibility identifier+(`settings-debug-disclosure`) on the label `Text` — an identifier on the group+itself is applied outside its content and masks every identifier inside it+(measured during the entry-detail work). `showingDebug` is a plain `@State`+defaulting to `false`, so the section is collapsed on every open (Q5).++The sync load path is untouched: `.task(id: syncModel?.status)` hangs off the+`List`, not the section, so collapsing the rows does not cancel or restart the+load, and `SettingsSyncModel` is `@State`-owned so its loaded counts survive+re-renders.++### Trade-offs++- A banner-worthy sync failure is invisible while Debug is collapsed (Q4).+  Accepted: the ticket explicitly makes sync information debug-tier, and backup+  export failures — including the torn-groups route to Check Library — still+  surface in the always-visible Backup section.+- The disclosure's expanded state is not persisted (Q5); persisting it would+  defeat the collapsed-by-default intent.+- Section order (Sites first) is not asserted by UI tests: frame-position+  comparisons are brittle in XCUITest and the order is one line of `body`.++## Expert Level++### Technical Deep Dive++The restructure is identity-stable. Both operands of the `debugSection` guard+are fixed for the view's lifetime (`diagnosticsModel` is a `let`; `syncModel`+is assigned once in `init`), so the branch cannot flip at runtime — no+structural identity churn, no lost `showingDebug` state. Collapsed, the+`DisclosureGroup` does not build its content, so the screen renders fewer rows+on open than the previous always-rendered iCloud section did.++The UI-test helper unification keeps the one behavioural asymmetry explicit:+entry detail scrolls with `scrollEntryDetail` (a drag from below the note+editor, because a `TextEditor` swallows `swipeUp`), while Settings — a plain+`List` — passes `{ $0.swipeUp() }`. The witness-row contract ("only the+witness's arrival proves the group opened") is what makes the identifier-on-+label rule *implicitly* tested: had the identifier landed on the group, the+inner rows would vanish from the tree and the witness would never arrive.++### Architecture Impact++None beyond the view layer. No model, wording, or navigation changes; all+model-facing initialisers are untouched, so `ContentView`'s construction of+`SettingsView` needed no edits. The shared disclosure core gives the next+collapsed section a two-line wrapper instead of a fourth copied loop.++### Potential Issues++- The collapsed-by-default assertion checks `settings-sync-export-line` does+  not exist after `settings-view` renders. If a future scenario opens Settings+  with the disclosure below the fold *and* expanded, lazy-`List` semantics+  would keep the row out of the tree and the assertion would pass vacuously;+  the subsequent expand-and-witness step still catches a moved-out-of-+  disclosure regression.+- `expandDisclosure` falls back to `app.staticTexts[labelText]`; a stray+  "Debug" static text elsewhere on the screen could receive the tap. The+  witness contract turns that into a retry rather than a false pass.++## Completeness Assessment++**Fully implemented**: every MUST/SHOULD in the smolspec — Sites first;+combined Backup section always rendered with the notice beside its repair;+Check Library out of it; Debug disclosure collapsed by default with the+identifier on the label; empty-Debug guard; all 22 pre-existing accessibility+identifiers unchanged (verified against origin/main); wording still+model-sourced. Verified by `make test-core`, a clean forced-recompile build,+and the affected UI suites (sync journey, diagnostics route, blocked-backup,+Sites, and an entry-detail journey exercising the refactored helper).++**Partially implemented**: nothing.++**Not covered by tests (accepted)**: Sites-first ordering and the empty-Debug+guard — both single lines of `body`, both brittle or unreachable to assert in+XCUITest with the current launch scenarios.diff --git a/specs/settings-cleanup/smolspec.md b/specs/settings-cleanup/smolspec.mdnew file mode 100644index 0000000..ed8e664--- /dev/null+++ b/specs/settings-cleanup/smolspec.md@@ -0,0 +1,79 @@+# Settings Cleanup++Transit ticket: T-2117++## Overview++The settings screen has grown organically: iCloud sync status sits at the top,+backup export and Check Library share a "Data" section, Sites is in the middle,+and the import controls hang at the bottom. This change restructures the screen+so the reader-facing controls lead and the diagnostic surfaces move into a+collapsed Debug section.++## Requirements++- The system MUST show the Sites section as the first section of the settings screen.+- The system MUST present backup export and backup import together in a single section.+- The system MUST show the interrupted-import notice in that same section, since importing a backup again is its repair (Q1).+- The system MUST NOT show Check Library in the import/export section.+- The system MUST provide a Debug section containing the iCloud sync information and the Check Library link.+- The system MUST render the Debug section collapsed by default, expandable by the reader, with an accessibility identifier on the expand control.+- The system MUST NOT render the Debug section when it would be empty (no sync model and no diagnostics model).+- The system MUST keep every existing accessibility identifier on the moved rows, so tests and assistive tech keep addressing the same elements.+- The system SHOULD keep all row wording sourced from the models — this change moves rows, it does not reword them.++## Implementation Approach++All view changes are in `Asterism/Asterism/Views/SettingsView.swift`:++- Reorder `body`'s `List` to: Sites section first (unchanged content, existing+  "Sites" header), then a combined section headed+  `ConstellationSectionHeader("Backup", accent: .violet)` (Q2) holding the+  interrupted-import notice, `backupRow`, and `SettingsBackupImportView` (the+  import view still only when `importModel` is present; the section itself+  always renders), then the Debug section last.+- Build the Debug section as a headerless `Section` containing a+  `DisclosureGroup(isExpanded:)` backed by a new+  `@State private var showingDebug = false`, holding the existing sync rows and+  the Check Library `NavigationLink`. Only render the section when+  `syncModel != nil || diagnosticsModel != nil`. Follow the+  `captureDetailsSection` pattern in+  `Asterism/Asterism/Views/EntryDetailView.swift` (line ~331): the disclosure+  label is a `Text("Debug")` carrying the accessibility identifier+  `settings-debug-disclosure` — the identifier goes on the *label*, not the+  group, because an identifier on the group overrides every identifier inside+  it (Q3).+- The sync load stays as-is: `.task(id: syncModel?.status)` is attached to the+  `List`, so the counts keep loading while the section is collapsed.+- Update UI tests that assume the old layout:+  - `AsterismUITests/AccessibilityJourneyUITests.swift` (sync lines) and+    `AsterismUITests/LibraryDiagnosticsUITests.swift` (Check Library button)+    must expand the Debug disclosure via `settings-debug-disclosure` before+    asserting on rows inside it.+  - `AsterismUITests/DuplicateResolutionUITests.swift` and+    `AsterismUITests/SitesSettingsUITests.swift` reference the export/sites+    identifiers and are expected to keep passing unchanged — verify, don't edit+    preemptively.++Dependencies: existing view models (`SettingsBackupModel`,+`SettingsBackupImportModel`, `SettingsSyncModel`, `LibraryDiagnosticsModel`,+`SitesListModel`) are untouched.++Out of Scope:+- No wording, model, or behaviour changes to sync status, backup, import, diagnostics, or sites.+- No persistence of the Debug section's expanded state across launches (Q5).+- No changes to the failed-export "Check Library" route inside `backupRow` — that link is part of the export failure flow and stays with the export row (Q4).++## Risks and Assumptions++- Risk: a `DisclosureGroup` identifier can mask every identifier inside it |+  Mitigation: put the identifier on the label, per the measured note in+  `EntryDetailView.swift`.+- Risk: UI tests fail because sync rows and Check Library are hidden until+  expanded | Mitigation: tests expand the Debug disclosure before asserting on+  those rows; run the affected UI suites via `make test-ui`.+- Risk: a banner-worthy sync failure is invisible while Debug is collapsed |+  Accepted per the ticket — sync information is explicitly debug-tier now (Q4);+  backup export failures still surface in the always-visible Backup section.+- Assumption: "sites management at the top" means the existing Sites+  `NavigationLink` section moves to first position — no new inline management UI.diff --git a/specs/settings-cleanup/tasks.md b/specs/settings-cleanup/tasks.mdnew file mode 100644index 0000000..eb06454--- /dev/null+++ b/specs/settings-cleanup/tasks.md@@ -0,0 +1,17 @@+---+references:+    - specs/settings-cleanup/smolspec.md+    - specs/settings-cleanup/decision_log.md+---+# Settings Cleanup (T-2117)++- [x] 1. Settings screen leads with Sites, followed by a combined Backup section holding the interrupted-import notice, backup export, and backup import — with Check Library no longer in it. Existing accessibility identifiers on all moved rows are unchanged. Verify: app builds and the reordered sections render with their existing identifiers. <!-- id:o25b3yj -->++- [x] 2. A Debug section at the bottom of Settings, collapsed by default, discloses the iCloud sync rows and the Check Library link; it renders only when sync or diagnostics models exist, and its expand control carries the settings-debug-disclosure identifier on the label. Verify: make test-core passes and no new compiler warnings. <!-- id:o25b3yk -->+  - Blocked-by: o25b3yj (Settings screen leads with Sites, followed by a combined Backup section holding the interrupted-import notice, backup export, and backup import — with Check Library no longer in it. Existing accessibility identifiers on all moved rows are unchanged. Verify: app builds and the reordered sections render with their existing identifiers.)++- [x] 3. UI tests that assert on sync rows (AccessibilityJourneyUITests) and Check Library (LibraryDiagnosticsUITests) expand the Debug disclosure first and pass against the new layout. Verify: the affected suites pass via make test-ui. <!-- id:o25b3yl -->+  - Blocked-by: o25b3yk (A Debug section at the bottom of Settings, collapsed by default, discloses the iCloud sync rows and the Check Library link; it renders only when sync or diagnostics models exist, and its expand control carries the settings-debug-disclosure identifier on the label. Verify: make test-core passes and no new compiler warnings.)++- [x] 4. The untouched suites (DuplicateResolutionUITests, SitesSettingsUITests) still pass against the reordered screen, confirming the export and sites identifiers stayed addressable. Verify: suites pass without edits, or with scroll-only adjustments if reordering moved rows off-screen. <!-- id:o25b3ym -->+  - Blocked-by: o25b3yk (A Debug section at the bottom of Settings, collapsed by default, discloses the iCloud sync rows and the Check Library link; it renders only when sync or diagnostics models exist, and its expand control carries the settings-debug-disclosure identifier on the label. Verify: make test-core passes and no new compiler warnings.)

Things to double-check

Debug section on a live phone.

The UI-test root cannot mirror, so the sync rows render the notRequested shape in every suite. Worth one manual open of Settings on a device with real sync state to see the failure and health lines inside the disclosure.

Vacuous-pass window in the collapsed assertion.

XCTAssertFalse(exists) is checked after settings-view renders; if a future scenario made the disclosure start expanded but below the fold, lazy-List semantics would keep the row out of the tree and the assertion would still pass. The expand-and-witness step that follows narrows, but does not close, that window.