Move test baseline to iOS 27 and disable automatic CI until a GA runner image has Xcode 27 - #548
Merged
Merged
Conversation
Switches the Xcode-dependent jobs from macos-26 (Xcode 26.6) to the xcode-27 image. The GA macos-26 image tops out at Xcode 26.6, so the beta xcode-27 image is currently the only one carrying Xcode 27. That image ships only the iOS 27.0 simulator runtime, so the snapshot device moves from iOS 26.5 to 27.0. This bumps the guard constant in AssertSnapshotWithDeviceCheck, updates the README testing table, and re-records 216 of the 248 reference images. The differences are sub-pixel rendering deltas between the two iOS versions — spot-checked against the old references, the content is unchanged. release-config stays on macos-26. It only exercises Ruby and fastlane, never Xcode, so there is no reason to expose it to a beta image. Also fixes anyPDFData, which built a page-less PDFDocument. That round-tripped through PDFDocument(data:) on iOS 26 but is rejected on iOS 27, failing three BackupImportFlowViewModel tests. Real export documents always carry pages — the PDF generator's own snapshot tests pass on iOS 27 — so this was only ever a test fixture defect, not a product one. Verified on Xcode 27.0 RC1: full suite green on iPhone 17 Pro / iOS 27.0, 13 bundles across both Default and TSAN configurations, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The project now builds and tests against Xcode 27 / iOS 27.0, but no GitHub-hosted GA image provides that combination. macos-26 tops out at Xcode 26.6 and has no iOS 27.0 simulator runtime. The beta xcode-27 image has both, but its runner pool could not absorb the 13-way test matrix — every shard sat queued indefinitely rather than running. Rather than depend on a beta pool that cannot schedule the work, the pull_request and push triggers are commented out and workflow_dispatch is left in place so the suite can still be run on demand. Everything else in the workflow is already pointed at Xcode 27 and iOS 27.0, and runs-on stays macos-26. Re-enabling should be a matter of uncommenting the two triggers once that image carries Xcode 27. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 13, 2026
bradleymackey
added a commit
that referenced
this pull request
Sep 13, 2026
Audits every Swift package dependency and brings the outdated ones
current.
## Audit result
Checked all 12 declared dependencies (10 source packages + 2 binary
targets) against their latest upstream release. Six were behind:
| Dependency | From | To |
| --- | --- | --- |
| `swift-snapshot-testing` | 1.19.2 | 1.19.4 |
| `BigInt` | 5.7.0 | 6.0.1 |
| `swift-argument-parser` | 1.6.2 | 1.8.2 |
| `swift-syntax` | 600.0.1 | 603.0.2 |
| `SwiftLintPlugins` | 0.63.3 | 0.65.1 |
| `SwiftFormat` (binary) | 0.61.1 | 0.63.0 |
Already current, left alone: `CryptoSwift` 1.10.0, `swiftui-toasts`
1.1.1, `CodeScanner` 2.5.2, `swift-security` 2.5.1, `swift-markdown-ui`
2.4.1, `mockolo` 2.6.1.
No transitive dependency moved — `xctest-dynamic-overlay`,
`swift-custom-dump`, `NetworkImage`, `swift-cmark` and
`swiftui-window-overlay` all resolve to the same revisions as before.
## Notes on the interesting ones
**`swift-syntax` was silently stuck.** It was the only dependency
declared `from: "600.0.0"` instead of `exact:`. SwiftPM treats `600` and
`603` as separate major versions, so that range could never reach the
current release — it had been pinned at 600.0.1 with no signal that
anything newer existed. It is now `exact: "603.0.2"` — the latest
release — matching every other dependency in the manifest and keeping
future drift visible in the diff.
**`BigInt`'s major bump is not a breaking change.** v6.0.0 contains only
a WASI `_mantissa` fix and added CI runners; v6.0.1 is a test-suite
migration to Swift Testing. The major version reflects a
`swift-tools-version` move to 6.0. Platform requirements are unchanged
and no API used by `CryptoEngine` was touched.
**`swift-argument-parser` 1.8.0 raises its minimum to Swift 6** —
satisfied, the project is on 6.4. 1.8.1 reverted the 1.8.0
source-compatibility regression around `parse()`/`parseAsRoot()`, so no
call-site change is needed.
**`SwiftLint` 0.64.0 has a config-breaking change** to
`force_unwrapping`'s `ignored_literal_argument_functions`, and renames
`allow_implicit_init` on `optional_data_string_conversion`. Neither rule
is configured in `.swiftlint.yml`, so no config migration was required.
## Source changes the tooling required
Two changes, both mechanical:
**SwiftLint 0.65.1 added `legacy_swiftui_aspect_ratio`**, which flagged
the single use of `.aspectRatio(contentMode:)` with a constant content
mode, in `PDFPageViewerView.swift:24`. Replaced with `.scaledToFit()` —
these are exactly equivalent (`scaledToFit()` is defined as
`aspectRatio(nil, contentMode: .fit)`). It was the only occurrence in
the package.
**SwiftFormat 0.63.0 reformatted 17 files** under two rules:
- Single-line `if x { stmt }` bodies expanded onto their own lines (15
files).
- Redundant SwiftUI `Group` wrappers removed, with `@ViewBuilder` added
where the wrapper had been supplying the builder context —
`OTPCodeDetailView.descriptionSection` and `VaultAutofillView.body`.
All of it is layout-only. No branch, condition, or error path changed.
That matters for the files touching killphrase and search-passphrase
handling (`VaultDataModel`, `VaultDetailKillphraseEditView`, and the two
rehash service test doubles) — the reformatting preserves branch
structure exactly, so the indistinguishability requirements in
`MANIFESTO.md` are unaffected.
The `Group` removals are the only changes with any theoretical rendering
risk, since they alter the resulting view's static type. Both sites sit
inside a `Form` alongside sibling `Section`s, where `Group` is
transparent. The snapshot suite confirms this empirically — see below.
## Verification
Local, Xcode 27.0 RC1 (`27A266a`), iPhone 18 Pro Max / iOS 27.0:
- `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED`, no warnings.
Note `-warnings-as-errors` is enabled package-wide, so any new
deprecation from the updated dependencies would have failed the build.
- Full suite, `-parallel-testing-enabled NO` — `TEST EXECUTE SUCCEEDED`,
**2848 tests passed, 0 failures, 0 crashes**, across both test plan
configurations (Default and TSAN).
- 112 snapshot assertions passed with **zero mismatches and zero
re-recordings**, and `git status` shows no change under any
`__Snapshots__` directory. This is the direct evidence that the `Group`
removals did not alter rendering.
- `make format` then `make lint` — clean and idempotent.
One cosmetic upstream warning now appears during `make lint`, from
BigInt's own manifest:
```
'bigint': .../BigInt/Package.swift:18:19: warning: 'v4' is deprecated: watchOS 9.0 is the oldest supported version
```
It originates inside the dependency's `Package.swift`, not our sources,
and does not fail the build or lint.
⚠️ Automatic CI is still disabled (#548), so none of this ran on a
runner — the verification above is entirely local.
## SwiftFormat's declared Swift version
`Vault/.swiftformat` declared `--swiftversion 6.2` while the project
builds with the Xcode 27 toolchain, which is **Swift 6.4**. SwiftFormat
uses this value to gate version-conditional rules, so a stale value
silently suppresses rules that only apply at newer language versions.
Bumped to `6.4`. Re-running `make format` against it produces **no
source changes whatsoever** — the entire diff is the config line. So
this carries no rendering or behavioural risk; it only ensures future
version-gated rules evaluate against the right version. `make lint` is
clean with it.
Because no source file changed, the build and test results above still
hold and were not re-run for this commit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
…551) Rebuilds the backup, restore and backup-password screens on standard SwiftUI form controls. ## The problem These screens had drifted away from the rest of the app. Each was a `ScrollView` of hand-rolled cards: `VaultCardModifier` with a coloured border, a tinted icon tile, and a full-width filled button *inside* the card. The border colour also carried state (green for active, red for error, orange for warning), which is not something iOS does. Nothing else in the app is built that way. Every other screen — settings, tags, item detail, and the note editing flow — uses `Form` + `Section` + `FormRow`, with explanatory copy in section footers. ## What changed | Screen | Change | | --- | --- | | `BackupCreateView` | One `Section` per capability; descriptions moved to section footers | | `BackupRestoreView` | Merge and override become plain rows; "Recommended" capsule becomes a section header | | `AutoBackupSettingsView` | Now vends a `Section` into its parent form instead of rendering its own card | | `BackupKeyChangeView` | Password entry becomes ordinary `SecureField` rows; keygen status moves to a footer; the historical-backup caution moves into the Details disclosure group | | `BackupImportFlowView` | The last fully card-based screen — root, ready-to-import step, and error/success states all become sections | | `BackupCreatePDFView` | "Make PDF" moves out of the section footer into its own row; error text becomes that section's footer | | `BackupKeyDecryptorView` | "Decrypt" moves out of the section footer into its own row | | `BackupGeneratedPDFView` | "Export & Save" moves into its own row; the red export reminder becomes a footer rather than a bordered card | | `DeviceTransferExportView` | "Try Again" becomes a plain row | Details worth calling out: - **Destructive emphasis is preserved, differently.** Import & Override used a red card border and a red filled button. It now uses standard red row text with a red icon — the same treatment iOS uses for destructive rows. - **The retention control** switched from `.pickerStyle(.segmented)` to the standard form picker, matching `VaultSettingsView`. - **The historical-backup caution moved into Details.** As a row with a leading icon it read as a tappable control. It now sits inside the Details disclosure group alongside About and Keygen Information. - **Primary actions are rows, not floating pills.** Several flow screens were already `Form`-based but placed their primary action inside a section footer as a padded, centred `ProminentButtonModifier` pill. Those are now ordinary button rows in their own section. - **Status and terminal states use `PlaceholderView` in a section**, which is what `DeviceTransferExportView` and `BackupKeyDecryptorView` already did for their generating, error and completed states. `BackupImportCodeScannerView` already matched the house style and is unchanged. **No `VaultCardModifier` or `ProminentButtonModifier` usage remains anywhere under `Views/Backup`.** Both modifiers themselves are untouched — they are still used by the item preview and detail screens, so only the backup screens stop using them. ## One flow change: password entry is presented immediately Importing an encrypted document used to add a "Decryption Password Needed" section to the top of the Import screen, which the user had to tap to reach the password field. There is nothing to decide at that point — the document is encrypted and the only way forward is the password — so entering `needsPasswordEntry` now presents the sheet directly and the section is gone. This needed a cancel path to be safe. `PayloadState` is `Equatable`, so leaving the state at `.needsPasswordEntry` after a dismissal would make a second import of the same document compare equal to the first, producing no change for `onChange` to react to — the sheet could never be re-presented and the user would be stuck on a screen with no route back to password entry. The same equality trap is why `.ready` already carries a UUID. `cancelPasswordEntry()` resets the state so that transition stays observable. It is guarded on `.needsPasswordEntry`, so a dismissal that follows a *successful* decode cannot clobber the `.ready` payload — the sheet's `onDismiss` fires for both outcomes. Both paths are covered by new tests. ## Behaviour is otherwise unchanged Every action, sheet, `task`, navigation path, file importer, publisher subscription, toolbar item and ordering is preserved. In particular `loadBackupPassword()` still runs before any import sheet is presented — that is now done once in a shared row builder rather than repeated at each call site. Reviewed against `MANIFESTO.md`, since backups are explicitly governed by it. Aside from the password-entry step above, which removes a tap without removing a decision, this is presentation-only: no bulk operation was added (C1), no enumeration UI (C5), no default weakened (C7), and nothing changed about what a backup payload contains or what a recipient can preview without decrypting (C10). **One copy change:** the override warning no longer opens with a "⚠️ Warning!" prefix, since the red row and footer already carry that weight. The text still states that on-device data will be lost if it is not in the backup, and the action still routes through the full import flow rather than destroying anything immediately. Flagging it as the only non-visual change in the diff. ## Snapshot test fix `BackupKeyChangeViewSnapshotTests` had a pre-existing defect, found while re-recording. The scenario loop built the view **once** and reused it across all six colour-scheme × type-size combinations. The view resets `permissionState` to `.undetermined` in `onDisappear`, so the first snapshot tore down the state the remaining five depended on — they silently captured the *locked* screen instead of the authenticated one. `layoutAuthenticated.*` was byte-identical to `layout.*` for five of six scenarios. Verified against `HEAD` before the change, so this predates this PR. Each scenario now builds its own view. All six authenticated snapshots now differ from their locked counterparts, so the authenticated layout — the part this PR rewrites most heavily — is actually covered. ## Verification Local, Xcode 27.0 RC1 (`27A266a`), iPhone 18 Pro Max / iOS 27.0: - `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED`, no warnings (`-warnings-as-errors` is on package-wide) - Full suite, `-parallel-testing-enabled NO` — `TEST EXECUTE SUCCEEDED`, **2852 passed, 0 failures**, across both configurations (Default and TSAN). Up 4 from the pre-change 2848 — the two new cancel-path tests, counted once per configuration. - 18 snapshots re-recorded (6 backup/restore, 12 key-change) and visually reviewed. The flow screens have view-model tests but no snapshot coverage, so there was nothing to re-record for them. - `make format` + `make lint` — clean⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
Points the in-app source code link at the renamed repository. The repo moved from `badbundle/vault-ios` to `badbundle/vault-app`, but the **View the source code on GitHub** link in Settings still pointed at the old name. It worked only because GitHub redirects renamed repositories — a redirect that stops working if the old name is ever claimed by another repo. ```diff -public static let openSourceLink = URL(string: "https://github.com/badbundle/vault-ios")! +public static let openSourceLink = URL(string: "https://github.com/badbundle/vault-app")! ``` ## Scope This was the **only** reference to the old name anywhere in the tree. The remaining `vault-ios` strings are all in `Vault/.claude/settings.local.json`, and those are local filesystem paths — the working directory is still literally named `vault-ios` on disk — not the repository URL, so they are correct as-is and untouched. ## No visible change The link's label is the localized string `openSource.aboutLink` ("View the source code on GitHub"), not the URL itself, so nothing rendered changes. Confirmed by the snapshot suite: zero reference images changed. ## Verification Local, Xcode 27.0 RC1 (`27A266a`), iPhone 18 Pro Max / iOS 27.0: - `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED`, no warnings - Full suite, `-parallel-testing-enabled NO` — `TEST EXECUTE SUCCEEDED`, **2852 passed, 0 failures**, both configurations - `git status` reports no change under any `__Snapshots__` directory - `make format` + `make lint` — clean⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 14, 2026
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
Removes six view files and two `Color` members that reimplement SwiftUI
APIs the app already uses elsewhere. Every one has zero call sites
outside its own file, so this is deletion only — no behaviour change.
| Deleted | Superseded by |
| --- | --- |
| `SearchTextField.swift` | `.searchable`, already used at
`VaultItemFeedView.swift:80` |
| `TextArea.swift` | `TextEditor`, already used in three places |
| `TextEditingView.swift` + `TextViewViewController.swift` |
`TextEditor` |
| `OTPCodeLabels.swift` | the inline `labelsStack` copies that replaced
it |
| `View+Center.swift` | `.frame(maxWidth:)` |
| `Color.contrastingForegroundColor` / `.contrastingBackgroudColor` |
unreferenced |
`TextEditingView` carried a doc comment explaining it existed to dodge
"bugs we've experienced with raw SwiftUI text editors". That workaround
was never in service — `SecureNoteDetailView` and `BackupCreatePDFView`
both use a plain `TextEditor` — so nothing regresses by removing it.
`HorizontallyCenter` (`HStack { Spacer(); content; Spacer() }`) is
replaced at its nine call sites by `.frame(maxWidth: .infinity)`, which
centres its child at its ideal size the same way. Six of those sites are
buttons whose pill chrome is applied by an inner modifier, so the button
keeps its intrinsic size rather than stretching.
## Verification
Local, iPhone 18 Pro Max / iOS 27.0:
- `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED`, no warnings
(`-warnings-as-errors` is on package-wide)
- Full suite, `-parallel-testing-enabled NO` — `TEST EXECUTE SUCCEEDED`,
2834 passed, 0 failures (1417 tests across 12 bundles, run under both
the Default and TSAN configurations)
- **Zero snapshots re-recorded.** That is the check that matters here:
if
any of these types had still been reachable, an image would have moved.
- `make format` + `make lint` — clean
⚠️ Automatic CI is still disabled (#548), so this is local verification
only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
Applies the button convention from #551 to the screens outside `Views/Backup`, and deletes the modifier that made the old treatment possible. ## The problem `ProminentButtonModifier` reimplemented `.borderedProminent` by hand: `.font(.headline)` + a forced `.foregroundStyle(.white)` + `padding(16/12)` + `.background(color)` + `RoundedRectangle(cornerRadius: 12)`. It applied `.buttonStyle(.borderless)` *inside* its own body, so the wrapped button never received a pressed state, and the hardcoded white label had no contrast guarantee against the tint it was placed on. Six of its nine call sites also placed the button inside a `Section` footer as a padded, centred pill — the exact pattern #551 called out and removed from the backup screens. ## What changed | Screen | Change | | --- | --- | | `SettingsDangerView` | "Delete All Data" becomes a red row in its own section; the error message becomes that section's footer | | `VaultTagDetailView` | "Delete Tag" becomes a red row | | `VaultItemDetailView` | "Unlock" and "Dismiss" move out of section footers into their own rows | | `EncryptedItemDetailView` | "Decrypt" moves out of the footer into its own section | | `VaultDetailEncryptionEditView` | "Encrypt" and "Remove Encryption" become plain rows | | `OTPCodeDetailView` | "Delete" moves out of the editing-actions footer into its own red section | | `SecureNoteDetailView` | "Delete" likewise | | `VaultAutofillConfigurationView` | "Continue" is a genuine standalone CTA outside any `Form`, so it becomes `.buttonStyle(.borderedProminent)` + `.controlSize(.large)` | Destructive emphasis is preserved the way #551 preserved it: a red `FormRow` glyph tile plus red row text, matching `BackupRestoreView`'s Import & Override. Primary actions use the accent colour the same way `BackupKeyDecryptorView` does. The `ProgressView` in each `loading:` branch loses its `.tint(.white)`, which only existed because the spinner sat on a filled pill. Toolbar *Cancel* buttons keep their explicit `.foregroundStyle(.red)`. `Button(role: .cancel)` does not render red in a toolbar, so dropping the tint would have been a visual regression rather than a standardization. ## Liquid Glass does not survive snapshot rendering "Continue" was first written as `.buttonStyle(.glassProminent)`. That rendered `VaultAutofillConfigurationView` as a **completely blank image** — not just the button, the whole hierarchy — dropping the reference from 151KB to 62KB. Liquid Glass samples a backdrop through the render server, and the `.image` snapshot strategy rasterises off-screen, so the effect resolves to nothing and takes the rest of the frame with it. `Snapshotting.image(drawHierarchyInKeyWindow:)` exists as a possible escape hatch but changes the rendering path for every test, so it is not something to adopt as a side effect of this PR. `.borderedProminent` is used instead. On iOS 26 the system already draws it as a capsule, which is the shape the hand-rolled 12pt rounded rectangle was imitating. ## Verification Local, iPhone 18 Pro Max / iOS 27.0: - `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED` - Affected suites pass: `OTPCodeDetailViewSnapshotTests`, `SecureNoteDetailViewSnapshotTests`, `VaultAutofillConfigurationViewSnapshotTests` - 43 snapshots re-recorded (18 OTP detail, 24 secure note, 1 autofill) and visually reviewed - `make format` + `make lint` — clean `SettingsDangerView`, `VaultTagDetailView`, `EncryptedItemDetailView` and `VaultDetailEncryptionEditView` have view-model tests but no snapshot coverage, so there was nothing to re-record for them.⚠️ Automatic CI is still disabled (#548), so this is local verification only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
…560) Applies the button convention from #551 to the screens outside `Views/Backup`, and deletes the modifier that made the old treatment possible. ## The problem `ProminentButtonModifier` reimplemented `.borderedProminent` by hand: `.font(.headline)` + a forced `.foregroundStyle(.white)` + `padding(16/12)` + `.background(color)` + `RoundedRectangle(cornerRadius: 12)`. It applied `.buttonStyle(.borderless)` *inside* its own body, so the wrapped button never received a pressed state, and the hardcoded white label had no contrast guarantee against the tint it was placed on. Six of its nine call sites also placed the button inside a `Section` footer as a padded, centred pill — the exact pattern #551 called out and removed from the backup screens. ## What changed | Screen | Change | | --- | --- | | `SettingsDangerView` | "Delete All Data" becomes a red row in its own section; the error message becomes that section's footer | | `VaultTagDetailView` | "Delete Tag" becomes a red row | | `VaultItemDetailView` | "Unlock" and "Dismiss" move out of section footers into their own rows | | `EncryptedItemDetailView` | "Decrypt" moves out of the footer into its own section | | `VaultDetailEncryptionEditView` | "Encrypt" and "Remove Encryption" become plain rows | | `OTPCodeDetailView` | "Delete" moves out of the editing-actions footer into its own red section | | `SecureNoteDetailView` | "Delete" likewise | | `VaultAutofillConfigurationView` | "Continue" is a genuine standalone CTA outside any `Form`, so it becomes `.buttonStyle(.borderedProminent)` + `.controlSize(.large)` | Destructive emphasis is preserved the way #551 preserved it: a red `FormRow` glyph tile plus red row text, matching `BackupRestoreView`'s Import & Override. Primary actions use the accent colour the same way `BackupKeyDecryptorView` does. The `ProgressView` in each `loading:` branch loses its `.tint(.white)`, which only existed because the spinner sat on a filled pill. Toolbar *Cancel* buttons keep their explicit `.foregroundStyle(.red)`. `Button(role: .cancel)` does not render red in a toolbar, so dropping the tint would have been a visual regression rather than a standardization. ## Liquid Glass does not survive snapshot rendering "Continue" was first written as `.buttonStyle(.glassProminent)`. That rendered `VaultAutofillConfigurationView` as a **completely blank image** — not just the button, the whole hierarchy — dropping the reference from 151KB to 62KB. Liquid Glass samples a backdrop through the render server, and the `.image` snapshot strategy rasterises off-screen, so the effect resolves to nothing and takes the rest of the frame with it. `Snapshotting.image(drawHierarchyInKeyWindow:)` exists as a possible escape hatch but changes the rendering path for every test, so it is not something to adopt as a side effect of this PR. `.borderedProminent` is used instead. On iOS 26 the system already draws it as a capsule, which is the shape the hand-rolled 12pt rounded rectangle was imitating. ## Verification Local, iPhone 18 Pro Max / iOS 27.0: - `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED` - Affected suites pass: `OTPCodeDetailViewSnapshotTests`, `SecureNoteDetailViewSnapshotTests`, `VaultAutofillConfigurationViewSnapshotTests` - 43 snapshots re-recorded (18 OTP detail, 24 secure note, 1 autofill) and visually reviewed - `make format` + `make lint` — clean `SettingsDangerView`, `VaultTagDetailView`, `EncryptedItemDetailView` and `VaultDetailEncryptionEditView` have view-model tests but no snapshot coverage, so there was nothing to re-record for them.⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
Replaces the hand-built chrome at the bottom of the vault feed with
standard controls.
## The problem
`VaultItemFeedView.unifiedInfoSection` was a hand-assembled bar:
`.background(Color.primary.opacity(0.05))` + `RoundedRectangle(12)`
wrapping two buttons that were styled entirely by hand — `.padding(8/16)`
+ `.background(...)` + `.clipShape(Capsule())` + a forced
`.foregroundStyle(.white)`. Neither had a pressed state.
The Clear button used `.background(Color.secondary)`. `Color.secondary`
is a *label* colour, not a fill; in dark mode that is a light grey behind
white text.
The tag filters were `TagPillView` + `.onTapGesture`, which produces no
`.isButton` trait, no VoiceOver activation, no keyboard or Switch Control
focus, and no press feedback. At `.footnote` with 8pt vertical padding
the tap target was roughly 29pt tall.
## What changed
| Element | Before | After |
| --- | --- | --- |
| Tag filters | `TagPillView` + `.onTapGesture` | `Toggle` + `.toggleStyle(.button)` + `.buttonStyle(.bordered)` + `.buttonBorderShape(.capsule)` + `.tint(tag colour)` |
| Clear | hand-built capsule | `.buttonStyle(.bordered)` + `.buttonBorderShape(.capsule)` |
| Edit/Done | hand-built capsule | `.buttonStyle(.borderedProminent)` + `.buttonBorderShape(.capsule)` |
| Bar container | `Color.primary.opacity(0.05)` + `RoundedRectangle(12)` | removed; the controls sit on the content the way a system bottom bar does |
| Animation | four stacked `.spring(response: 0.3, dampingFraction: 1.0)` | `.snappy` |
| Status label | `.foregroundColor(.secondary)` repeated on seven sibling views | one `.foregroundStyle(.secondary)` on the container |
Selected and unselected tag states now come from the toggle style rather
than the brightness-derived fill and stroke colours, so the pills pick up
the standard tinted/untinted treatment. `TagPillView` itself is unchanged
and still used by the three read-only sites that display an item's tags.
`VaultListView`'s add-item menu label becomes
`Label("Add Item", systemImage: "plus")` instead of a bare `Image`.
Reordering still uses `.draggable`/`.dropDestination` and
`VaultItemFeedReorderer`. The `LazyVGrid` is deliberate, and `List.onMove`
would force a single-column layout.
## Why this is not a `.toolbar`
The obvious native move is to put this in `.toolbar` — item count in
`ToolbarItem(placement: .status)`, `EditButton()` and Clear in
`.bottomBar` — and let iOS 26 render the bar on Liquid Glass.
That was tried and reverted. Toolbar content does not rasterise reliably
in the snapshot harness: the bar drew twice (once over the navigation
title, once at the bottom), the conditional Clear button never appeared
even with two tags active, and `EditButton()` still read "Edit" in a
snapshot where the cards had already rendered their editing state — so
the `\.editMode` binding had not been applied at capture time.
This is the same class of problem as the Liquid Glass finding in the
previous commit: UIKit-hosted chrome does not resolve when the hierarchy
is rendered off-screen. Moving this chrome into a toolbar would therefore
have silently gutted the six `unifiedBar_*` tests that exist to cover it.
The bar stays in `.safeAreaInset`, which rasterises correctly, and the
standardization is achieved through the control styles instead.
## Verification
Local, iPhone 18 Pro Max / iOS 27.0:
- `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED`
- Full suite, `-parallel-testing-enabled NO` — `TEST EXECUTE SUCCEEDED`,
2834 passed, 0 failures across 24 bundles, matching the pre-change count
- 15 snapshots re-recorded and visually reviewed: 13 feed, plus
`VaultMainNavigationView` and `VaultAutofillCodeSelectorView`, which
both embed the feed
- `make format` + `make lint` — clean
⚠️ Automatic CI is still disabled (#548), so this is local verification only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
Replaces the hand-built chrome at the bottom of the vault feed with
standard controls.
## The problem
`VaultItemFeedView.unifiedInfoSection` was a hand-assembled bar:
`.background(Color.primary.opacity(0.05))` + `RoundedRectangle(12)`
wrapping two buttons that were styled entirely by hand —
`.padding(8/16)`
+ `.background(...)` + `.clipShape(Capsule())` + a forced
`.foregroundStyle(.white)`. Neither had a pressed state.
The Clear button used `.background(Color.secondary)`. `Color.secondary`
is a *label* colour, not a fill; in dark mode that is a light grey
behind
white text.
The tag filters were `TagPillView` + `.onTapGesture`, which produces no
`.isButton` trait, no VoiceOver activation, no keyboard or Switch
Control
focus, and no press feedback. At `.footnote` with 8pt vertical padding
the tap target was roughly 29pt tall.
## What changed
| Element | Before | After |
| --- | --- | --- |
| Tag filters | `TagPillView` + `.onTapGesture` | `Toggle` +
`.toggleStyle(.button)` + `.buttonStyle(.bordered)` +
`.buttonBorderShape(.capsule)` + `.tint(tag colour)` |
| Clear | hand-built capsule | `.buttonStyle(.bordered)` +
`.buttonBorderShape(.capsule)` |
| Edit/Done | hand-built capsule | `.buttonStyle(.borderedProminent)` +
`.buttonBorderShape(.capsule)` |
| Bar container | `Color.primary.opacity(0.05)` + `RoundedRectangle(12)`
| removed; the controls sit on the content the way a system bottom bar
does |
| Animation | four stacked `.spring(response: 0.3, dampingFraction:
1.0)` | `.snappy` |
| Status label | `.foregroundColor(.secondary)` repeated on seven
sibling views | one `.foregroundStyle(.secondary)` on the container |
Selected and unselected tag states now come from the toggle style rather
than the brightness-derived fill and stroke colours, so the pills pick
up
the standard tinted/untinted treatment. `TagPillView` itself is
unchanged
and still used by the three read-only sites that display an item's tags.
`VaultListView`'s add-item menu label becomes
`Label("Add Item", systemImage: "plus")` instead of a bare `Image`.
Reordering still uses `.draggable`/`.dropDestination` and
`VaultItemFeedReorderer`. The `LazyVGrid` is deliberate, and
`List.onMove`
would force a single-column layout.
## Why this is not a `.toolbar`
The obvious native move is to put this in `.toolbar` — item count in
`ToolbarItem(placement: .status)`, `EditButton()` and Clear in
`.bottomBar` — and let iOS 26 render the bar on Liquid Glass.
That was tried and reverted. Toolbar content does not rasterise reliably
in the snapshot harness: the bar drew twice (once over the navigation
title, once at the bottom), the conditional Clear button never appeared
even with two tags active, and `EditButton()` still read "Edit" in a
snapshot where the cards had already rendered their editing state — so
the `\.editMode` binding had not been applied at capture time.
This is the same class of problem as the Liquid Glass finding in the
previous commit: UIKit-hosted chrome does not resolve when the hierarchy
is rendered off-screen. Moving this chrome into a toolbar would
therefore
have silently gutted the six `unifiedBar_*` tests that exist to cover
it.
The bar stays in `.safeAreaInset`, which rasterises correctly, and the
standardization is achieved through the control styles instead.
## Verification
Local, iPhone 18 Pro Max / iOS 27.0:
- `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED`
- Full suite, `-parallel-testing-enabled NO` — `TEST EXECUTE SUCCEEDED`,
2834 passed, 0 failures across 24 bundles, matching the pre-change count
- 15 snapshots re-recorded and visually reviewed: 13 feed, plus
`VaultMainNavigationView` and `VaultAutofillCodeSelectorView`, which
both embed the feed
- `make format` + `make lint` — clean
⚠️ Automatic CI is still disabled (#548), so this is local verification
only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
…pper `OpenSourceView` and `VaultAutofillConfigurationView` were the only two screens in the app with no `Form` or `List` anywhere. Both faked inset-grouped styling by hand. ## OpenSourceView A `GeometryReader` + `ScrollView` + `VStack` with `.frame(minHeight: geometry.size.height)` to fake vertical centring, and a bare `Link` styled with `.foregroundStyle(.tint)`. Now a `Form` matching `VaultAboutView`: a `PlaceholderView` header section, a section holding the two paragraphs, and the GitHub link as a `FormRow` row. The row uses the same purple glyph tile that `VaultAboutView` already uses for its Open Source entry, so the two screens agree. ## VaultAutofillConfigurationView A `ScrollView` + centred `VStack` + `Spacer()`s + a double `containerRelativeFrame`, with two hand-drawn `RoundedRectangle(cornerRadius: 12).fill(.secondarySystemGroupedBackground)` "cards" imitating inset-grouped list rows. Now a real `List`: the hero is a section, the two features are ordinary `Label` rows (so they get the system's row separator and alignment), and the Continue button moves into `.safeAreaInset(edge: .bottom)`. Fixed sizes on text are gone with it — `.system(size: 28, weight: .bold)` on the title becomes `.title.bold()`, and the supporting line becomes `.headline`. Both scale with Dynamic Type now. The 64pt header glyph stays fixed, since it is decorative, but takes `.tint` instead of a literal `.blue`. ## FolderPickerView `AutoBackupSettingsView` presented a `.sheet` containing a `UIViewControllerRepresentable` around `UIDocumentPickerViewController`, with its own `Coordinator` and delegate, purely to pick a folder. `.fileImporter(isPresented:allowedContentTypes: [.folder])` does this natively, and `BackupImportFlowView` already uses `.fileImporter` elsewhere. The wrapper and its coordinator are deleted (~30 lines). Both APIs vend a security-scoped URL and `configureSelectedProvider(with:)` is unchanged, so the handling either side of the picker is the same. A cancelled pick is ignored rather than surfaced, matching the old delegate, which only forwarded on `didPickDocumentsAt`. ## Verification Local, iPhone 18 Pro Max / iOS 27.0: - `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED` - Full suite, `-parallel-testing-enabled NO` — `TEST EXECUTE SUCCEEDED`, 2834 passed, 0 failures across 24 bundles - 2 snapshots re-recorded (`OpenSourceView`, `VaultAutofillConfigurationView`) and visually reviewed - `make format` + `make lint` — clean⚠️ The folder-picker change is **not** covered by an automated test — a file importer cannot be driven from a snapshot test. It needs a manual run: pick a folder, confirm the security-scoped bookmark still resolves and that auto-backup writes to it.⚠️ Automatic CI is still disabled (#548), so this is local verification only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
…pper (#556) `OpenSourceView` and `VaultAutofillConfigurationView` were the only two screens in the app with no `Form` or `List` anywhere. Both faked inset-grouped styling by hand. ## OpenSourceView A `GeometryReader` + `ScrollView` + `VStack` with `.frame(minHeight: geometry.size.height)` to fake vertical centring, and a bare `Link` styled with `.foregroundStyle(.tint)`. Now a `Form` matching `VaultAboutView`: a `PlaceholderView` header section, a section holding the two paragraphs, and the GitHub link as a `FormRow` row. The row uses the same purple glyph tile that `VaultAboutView` already uses for its Open Source entry, so the two screens agree. ## VaultAutofillConfigurationView A `ScrollView` + centred `VStack` + `Spacer()`s + a double `containerRelativeFrame`, with two hand-drawn `RoundedRectangle(cornerRadius: 12).fill(.secondarySystemGroupedBackground)` "cards" imitating inset-grouped list rows. Now a real `List`: the hero is a section, the two features are ordinary `Label` rows (so they get the system's row separator and alignment), and the Continue button moves into `.safeAreaInset(edge: .bottom)`. Fixed sizes on text are gone with it — `.system(size: 28, weight: .bold)` on the title becomes `.title.bold()`, and the supporting line becomes `.headline`. Both scale with Dynamic Type now. The 64pt header glyph stays fixed, since it is decorative, but takes `.tint` instead of a literal `.blue`. ## FolderPickerView `AutoBackupSettingsView` presented a `.sheet` containing a `UIViewControllerRepresentable` around `UIDocumentPickerViewController`, with its own `Coordinator` and delegate, purely to pick a folder. `.fileImporter(isPresented:allowedContentTypes: [.folder])` does this natively, and `BackupImportFlowView` already uses `.fileImporter` elsewhere. The wrapper and its coordinator are deleted (~30 lines). Both APIs vend a security-scoped URL and `configureSelectedProvider(with:)` is unchanged, so the handling either side of the picker is the same. A cancelled pick is ignored rather than surfaced, matching the old delegate, which only forwarded on `didPickDocumentsAt`. ## Verification Local, iPhone 18 Pro Max / iOS 27.0: - `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED` - Full suite, `-parallel-testing-enabled NO` — `TEST EXECUTE SUCCEEDED`, 2834 passed, 0 failures across 24 bundles - 2 snapshots re-recorded (`OpenSourceView`, `VaultAutofillConfigurationView`) and visually reviewed - `make format` + `make lint` — clean⚠️ The folder-picker change is **not** covered by an automated test — a file importer cannot be driven from a snapshot test. It needs a manual run: pick a folder, confirm the security-scoped bookmark still resolves and that auto-backup writes to it.⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
The app's primary content — the OTP code — was a fixed 36pt, and its status text was 7pt. Neither responded to Dynamic Type at all. ## Fixed point sizes become text styles | Location | Before | After | | --- | --- | --- | | `LoadingBarLabel` | `.system(size: 7, weight: .semibold)` | `.caption2.weight(.semibold)` | | `EncryptedItemPreviewView` badge | `.system(size: 9, weight: .medium)` | `.caption2.weight(.medium)` | | `TOTPCodePreviewView`, `HOTPCodePreviewView`, `OTPWidgetSmallView` | `.system(size: 36, design: .monospaced)` | `.system(.largeTitle, design: .monospaced)` | | `OTPCodeButtonView` | `.system(size: 24, weight:)` | `.title2.weight(_:)` | | `BackupImportCodeStateVisualizerView` | `.system(size: 28)` / `.system(size: 24).bold()` | `.largeTitle` / `.title2.bold()` | 7pt is well below the legibility floor, and it was the only text carrying "Code locked", "Update required" and the error titles. The OTP code keeps its exact monospaced look — `.system(_:design:)` has a *text style* overload, which `OTPCodeTextView` was already using in its own preview. Only the scaling behaviour changes. The three remaining `.system(size:)` uses are decorative glyphs (the 64pt autofill header, the 100pt QR placeholder, and `FormRow`'s tile glyph) and are left alone. ## The `String.count` font ladders are gone Five copies of the same anti-pattern — a `switch` on the title's character count picking between a text style and two or three fixed point sizes: - `TOTPCodePreviewView`, `HOTPCodePreviewView` and `OTPWidgetSmallView` held **the same `issuerFont` three times** - `SecureNotePreviewView` had an eight-way tuple variant - `EncryptedItemPreviewView` a four-way one Every one bottomed out at a fixed 14–20pt, so a user at an accessibility text size saw 14pt text inside a card sized for their setting — the exact inversion of what Dynamic Type is for. Each is replaced by one text style plus `.minimumScaleFactor(0.7)` and `.allowsTightening(true)`, which is what actually does the fitting. `SecureNotePreviewView` keeps its one meaningful distinction — the title is heavier when there is no description to share the card with — now expressed as a single ternary rather than a tuple ladder. ## Metrics that box text now scale - `FormRow`'s 28pt glyph tile and `PlaceholderView`'s 40pt icon frame become `@ScaledMetric`. The `PlaceholderView` frame was clipping a `.largeTitle` glyph at accessibility sizes. - `OTPCodeDetailView`'s preview card: `.frame(width: 180)` → `.frame(maxWidth: 240)`. - `VaultAboutView`'s logo: `.frame(height: 21.6)` → `22`. A fractional point height lands off the pixel grid. ## The global appearance proxy is gone `VaultMainScene.init` called `UITextView.appearance().textContainerInset = …`, which applied to every `UITextView` in the process — including ones the app does not own. The three `TextEditor`s that relied on it now set `.contentMargins(12, for: .scrollContent)` themselves. `SelectableText` also relied on it and is not a SwiftUI scroll view, so it sets `textContainerInset` on its own `UITextView`. While there, `SelectableText` stops scaling a hardcoded 16pt base and derives from the text style's own font, so weight and tracking match the style. Note `preferredFont(forTextStyle:compatibleWith:)` is already scaled for the given content size category — it must not also be passed through `UIFontMetrics`, which would scale it twice. ## Verification Local, iPhone 18 Pro Max / iOS 27.0: - `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED` - Full suite, `-parallel-testing-enabled NO` — `TEST EXECUTE SUCCEEDED`, 2834 passed, 0 failures across 24 bundles - 150 snapshots re-recorded, of which 101 changed, across 12 suites; the remaining 49 re-recorded byte-identical. Reviewed with attention to the `xxLarge` variants, which are the point of the change. - `make format` + `make lint` — clean⚠️ Automatic CI is still disabled (#548), so this is local verification only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
The app's primary content — the OTP code — was a fixed 36pt, and its status text was 7pt. Neither responded to Dynamic Type at all. ## Fixed point sizes become text styles | Location | Before | After | | --- | --- | --- | | `LoadingBarLabel` | `.system(size: 7, weight: .semibold)` | `.caption2.weight(.semibold)` | | `EncryptedItemPreviewView` badge | `.system(size: 9, weight: .medium)` | `.caption2.weight(.medium)` | | `TOTPCodePreviewView`, `HOTPCodePreviewView`, `OTPWidgetSmallView` | `.system(size: 36, design: .monospaced)` | `.system(.largeTitle, design: .monospaced)` | | `OTPCodeButtonView` | `.system(size: 24, weight:)` | `.title2.weight(_:)` | | `BackupImportCodeStateVisualizerView` | `.system(size: 28)` / `.system(size: 24).bold()` | `.largeTitle` / `.title2.bold()` | 7pt is well below the legibility floor, and it was the only text carrying "Code locked", "Update required" and the error titles. The OTP code keeps its exact monospaced look — `.system(_:design:)` has a *text style* overload, which `OTPCodeTextView` was already using in its own preview. Only the scaling behaviour changes. The three remaining `.system(size:)` uses are decorative glyphs (the 64pt autofill header, the 100pt QR placeholder, and `FormRow`'s tile glyph) and are left alone. ## The `String.count` font ladders are gone Five copies of the same anti-pattern — a `switch` on the title's character count picking between a text style and two or three fixed point sizes: - `TOTPCodePreviewView`, `HOTPCodePreviewView` and `OTPWidgetSmallView` held **the same `issuerFont` three times** - `SecureNotePreviewView` had an eight-way tuple variant - `EncryptedItemPreviewView` a four-way one Every one bottomed out at a fixed 14–20pt, so a user at an accessibility text size saw 14pt text inside a card sized for their setting — the exact inversion of what Dynamic Type is for. Each is replaced by one text style plus `.minimumScaleFactor(0.7)` and `.allowsTightening(true)`, which is what actually does the fitting. `SecureNotePreviewView` keeps its one meaningful distinction — the title is heavier when there is no description to share the card with — now expressed as a single ternary rather than a tuple ladder. ## Metrics that box text now scale - `FormRow`'s 28pt glyph tile and `PlaceholderView`'s 40pt icon frame become `@ScaledMetric`. The `PlaceholderView` frame was clipping a `.largeTitle` glyph at accessibility sizes. - `OTPCodeDetailView`'s preview card: `.frame(width: 180)` → `.frame(maxWidth: 240)`. - `VaultAboutView`'s logo: `.frame(height: 21.6)` → `22`. A fractional point height lands off the pixel grid. ## The global appearance proxy is gone `VaultMainScene.init` called `UITextView.appearance().textContainerInset = …`, which applied to every `UITextView` in the process — including ones the app does not own. The three `TextEditor`s that relied on it now set `.contentMargins(12, for: .scrollContent)` themselves. `SelectableText` also relied on it and is not a SwiftUI scroll view, so it sets `textContainerInset` on its own `UITextView`. While there, `SelectableText` stops scaling a hardcoded 16pt base and derives from the text style's own font, so weight and tracking match the style. Note `preferredFont(forTextStyle:compatibleWith:)` is already scaled for the given content size category — it must not also be passed through `UIFontMetrics`, which would scale it twice. ## Verification Local, iPhone 18 Pro Max / iOS 27.0: - `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED` - Full suite, `-parallel-testing-enabled NO` — `TEST EXECUTE SUCCEEDED`, 2834 passed, 0 failures across 24 bundles - 150 snapshots re-recorded, of which 101 changed, across 12 suites; the remaining 49 re-recorded byte-identical. Reviewed with attention to the `xxLarge` variants, which are the point of the change. - `make format` + `make lint` — clean⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 14, 2026
## Reduce Motion
`Shimmer` runs `.linear(duration: 1.5).repeatForever(autoreverses: false)`
— an indefinitely repeating animation — and it is applied to every card in
the feed simultaneously while editing. There was no
`accessibilityReduceMotion` check anywhere in the codebase.
`.shimmering(active:)` now routes through a wrapper that drops the effect
entirely when Reduce Motion is on. The shimmer itself is unchanged.
The obvious alternative was to replace `Shimmer` with
`.redacted(reason: .placeholder)`, which is reduce-motion aware for free.
That is not equivalent here: `.redacted` replaces content with grey
placeholder capsules, whereas these call sites shimmer a card that is
still meant to show its own text ("is editing", "Tap to View"). Swapping
them would change what the card says, not just how it animates.
## Semantic colours for control tracks
`systemGray2`/`systemGray6` are *background-ramp* colours and were being
used as the fill behind a progress indicator. Both timer bars now use
`Color(.quaternarySystemFill)`, which is the ramp intended for controls.
`CodeTimerHorizontalBarView`'s default `color` was a literal `.blue`, and
`VaultCardModifier`'s `.prominent` background was `Color.blue`. Both are
now `.accentColor`, so they follow the app's accent instead of pinning
themselves to blue.
## Animations
All 22 `.animation(.easeOut, …)` sites become `.snappy`. `.easeOut` reads
flat and mechanical next to the system's own transitions; `.snappy` is
the current idiom. The feed's `.easeOut(duration: 0.1)` went with them —
100ms reads as a jump rather than a transition.
## Deprecations
The 16 remaining `.foregroundColor(_:)` call sites become
`.foregroundStyle(_:)`, which is its replacement and the only one of the
two that accepts hierarchical and material styles.
## Not done, deliberately
**The in-app timer bar keeps its hand-built implementation.** Converting
it to `ProgressView(value:total:)` + `.progressViewStyle(.linear)` was
planned, on the grounds that the widget already renders the same countdown
with `ProgressView(timerInterval:)`.
Two reasons not to:
- `.progressViewStyle(.linear)` draws a ~4pt hairline. These bars are a
deliberately chunky 12pt filled indicator, so the swap would be a
visual regression on the app's most-viewed screen, not a
standardization.
- `ProgressView(timerInterval:)` specifically cannot be used in-app. The
bar is driven by the injected `injector.clock` (an `EpochClockMock`
under test) so that snapshots are deterministic; `timerInterval:`
renders against wall-clock `Date` and would make every OTP snapshot
time-dependent. The widget can use it because a widget timeline is
already `Date`-based.
The accessibility value this would have bought — a free
`.accessibilityValue` on the bar — belongs with the accessibility pass,
which is out of scope for this series.
The two animation-suppression workarounds (`PlaceholderView`'s
`.transaction { $0.animation = nil }` and `BackupKeyChangeView`'s
`.animation(.none, value:)`) are also left alone. Removing them requires
re-scoping a broad `.animation(_:value:)` higher in the tree, which risks
a behaviour change for no visual gain.
## Verification
Local, iPhone 18 Pro Max / iOS 27.0:
- `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED`
- Full suite, `-parallel-testing-enabled NO` — 2834 passed, 0 failures
across 24 bundles
- 3 snapshots re-recorded (`HorizontalTimerProgressBarView`, from the
track colour) and visually reviewed
- `make format` + `make lint` — clean
⚠️ Automatic CI is still disabled (#548), so this is local verification only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
## Problem Killphrase digests are created from trimmed phrases (`VaultDataModelEditorAdapter`), but matching used the raw `itemsSearchQuery` while the search predicate used the trimmed `itemsSanitizedQuery`. A trailing space from the iOS keyboard meant the search matched visually but the killphrase silently never fired. ## Fix - `KillphraseDigester` now normalizes (trims whitespace/newlines) in both `makeDigest` and `matches`. Backward compatible: all persisted digests were already computed from trimmed phrases, and trim is idempotent. No case/canonical fold — existing digests were computed without one and killphrases stay exact-match otherwise. - `VaultDataModel.reloadItems()` passes the sanitized query to the deleter, so match input equals the search-predicate input. - MANIFESTO C2 preserved: no new throw/log/observable branch; the deleter's silent-failure contract is untouched. ## Tests - Digester: trailing/leading whitespace matches, write-side trim, interior whitespace not trimmed. - `VaultDataModel`: untrimmed search query reaches the deleter sanitized; new fail-safe negatives — deleter is never invoked when the digester was never loaded or the key store fails (previously untested lock-state behaviour). - Store level: `deleteItems(matchingKillphrase: "phrase ")` deletes an item whose phrase is `"phrase"`. Local verification: VaultFeedTests scheme, 852 tests in 72 suites, all passed on iPhone 18 Pro Max / iOS 27.0.⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
) ## Problem `AutoBackupServiceImpl.triggerBackupIfNeeded()` compares `dataModel.currentPayloadHash` against `configuration.lastBackupHash` and skips the backup when they match. Three mutation paths changed the vault without refreshing that hash, so auto-backup silently skipped them: - **Killphrase deletion** (`reloadItems()`): fired `onDataChanged` but never refreshed the hash — so the killed items remained recoverable from the newest auto-backup, undermining the killphrase (MANIFESTO C6/C10 spirit). - **`insert(item:)`**: refreshed neither the hash nor fired `onDataChanged` — newly created items never triggered an auto-backup until some later update/delete. - **`incrementCounter(id:)`** (HOTP): fired `onDataChanged` without the hash refresh. `update`/`delete`/`reorder`/tag mutations already did both; these three now match. ## Fix Add `await updateCurrentPayloadHash()` (and `onDataChanged?()` for insert) to the three paths, ordered the same as the existing siblings. Plain reloads (every search keystroke) still do **not** export/recompute — the refresh happens only when a killphrase actually deleted something, and a test pins that. C3 note: `notifyDataChanged` remains cause-agnostic — nothing records that a change was killphrase-caused. ## Tests - `reloadItems_refreshesPayloadHashWhenKillphraseDeletesItems` - `reloadItems_doesNotRefreshPayloadHashWhenNoKillphraseDeletionOccurs` (no per-keystroke export regression) - `insert_refreshesPayloadHashAndNotifiesDataChanged` - `incrementCounter_refreshesPayloadHash` - Integration guard: `triggerBackupIfNeeded_runsAfterKillphraseDeletionChangesHash` — real `VaultDataModel` wired to `AutoBackupServiceImpl`; backup taken, killphrase fires, trigger must write a second backup. - Updated the exact `calledMethods` sequence assertions for insert/incrementCounter. Local verification: VaultFeedTests scheme, all tests passed on iPhone 18 Pro Max / iOS 27.0.⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
## Problem TOTP preview codes are obfuscated when the scene leaves `.active` (privacy cover for the app switcher), but the `.active` case only restarted timers — there was no unobfuscate path at all for TOTP (`unobfuscateForPrivacy()` existed only on the HOTP repository). A transient `.inactive` (Control Centre, system alert, app switcher peek) left every TOTP code showing as obfuscated until the next timer emission. ## Fix Mirror the HOTP semantics exactly: - `TOTPPreviewViewRepository` gains `unobfuscateForPrivacy()` (protocol + impl), iterating the cached view models with `updateRemovePrivacyObfuscation()` — same shape as `HOTPPreviewViewRepositoryImpl`. - `TOTPPreviewViewGenerator.scenePhaseDidChange(.active)` now unobfuscates before restarting timers, so codes reappear immediately instead of waiting for the next tick. The privacy direction (obfuscate on `.background`/`.inactive`) is untouched. ## Tests - Generator: `.active` calls unobfuscate + restart (renamed test, mirrors the HOTP generator suite); `.background`/`.inactive` assert unobfuscate is never called. - Repository: `unobfuscateForPrivacy_unobfuscatesCodesHiddenForPrivacy` — visible code → privacy-obfuscated → restored (mirrors the HOTP repository test). Local verification: VaultFeedTests + VaultiOSTests schemes, all passed on iPhone 18 Pro Max / iOS 27.0.⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
…#564) ## Problem The backup key-change screen advertised "up to 3 minutes" of key derivation but was uncancellable in practice: - The Cancel toolbar button was `.disabled(isLoading)` — disabled exactly while the keygen ran, with `interactiveDismissDisabled` also active, so the `.keygenCancelled` state was unreachable from the UI. - `onDisappear` did not cancel `keyGenerationTask`, so a dismissed view could still complete `store(backupPassword:)` in the background and silently replace the user's backup password. - Even a cancelled task would complete the store: the KDF body is synchronous (cancellation can't interrupt it) and there was no cancellation check between keygen and store. - The plaintext password fields were cleared only on the success path — retained in the view model on keygen error, cancellation, and after the view disappeared. ## Fix - Cancel stays enabled during `.creating` (comment documents why); swipe-dismiss remains blocked. - `onDisappear` cancels the in-flight keygen task before resetting state. - `try Task.checkCancellation()` after the KDF returns and **before** the derived key replaces the stored password — cancellation is now authoritative; worst case the CPU work completes in the detached task and is discarded, leaving the old backup password intact. - Entered passwords cleared in `didDisappear()` and on the keygen-error/cancelled paths. Deliberately retained on confirm-mismatch (user is mid-correction, view still frontmost — documented inline). ## Tests - `saveEnteredPassword_cancelledBeforeStore_setsKeygenCancelledAndDoesNotStore` (store mock `set` never called) - `saveEnteredPassword_cancelled_clearsEnteredPasswords`, `_keygenError_clearsEnteredPasswords`, `_passwordConfirmError_retainsEnteredPasswords`, `didDisappear_clearsEnteredPasswords` - New snapshot `layoutCreatingState` (light/dark): view pinned in `.creating` via a blocking test deriver, wrapped in a `NavigationStack` so the toolbar renders — the enabled Cancel button is the point of the image. Local verification: VaultFeedTests + VaultiOSTests schemes passed on iPhone 18 Pro Max / iOS 27.0 (snapshot recorded, then clean pass).⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
## Problem The Danger Zone — the single most destructive surface in the app — had almost no coverage: one failure-path test on the view model (`LightweightViewModelCoverageTests`) and no view test at all, despite the screen being rebuilt in #560. ## Changes (test-only) New `SettingsDangerViewModelTests`: - `deleteEntireVault_success_callsDeleterAndClearsAutofillStore` — happy path: store deleter invoked once, OTP autofill identities cleared, `isDeleting` observed true in flight and false after (the deliberate 2-second completion delay makes this test ~2s wall clock; accepted rather than refactoring the delay out pre-release). - `deleteEntireVault_deleterFailure_throwsPresentationErrorAndResetsState` — deleter failure surfaces as `PresentationError`, autofill store untouched. - `deleteEntireVault_requiresAuthenticationBeforeDeleting` — denied device auth means the deleter is never called (MANIFESTO C4: auth gates the unattended-device threat). New `SettingsDangerViewSnapshotTests` — first snapshots of the rebuilt screen, light/dark × xSmall/medium/xxLarge. No production code changes. (Noted for the release-findings list: `deleteVault()` does not refresh the auto-backup payload hash, so the newest auto-backup still describes the deleted vault — whether that is a recovery safety net or a C6 problem is a design decision, not patched here.) Local verification: targeted suites passed on iPhone 18 Pro Max / iOS 27.0 (snapshots recorded, then clean pass).⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
## Problem `UTType(exportedAs: "vault.identifier.drop.id")` in `VaultItem+Transferable.swift` is evaluated in every bundle that links `VaultiOS` — including the autofill extension, whose code selector embeds `VaultItemFeedView` and therefore registers the `.draggable`/`.dropDestination` transfer types. `exportedAs` on an identifier the calling bundle does not declare is a programming error at runtime (fault log, undefined type resolution), and: - `VaultAppAutofill/Info.plist` declared nothing at all; - the main app's `UTExportedTypeDeclarations` entry was malformed — `UTTypeConformsTo` containing a single empty string and an empty `UTTypeTagSpecification` dict. ## Fix - Code: `UTType(importedAs: "vault.identifier.drop.id", conformingTo: .data)` — correct for shared code evaluated in multiple bundles; never faults on an undeclared identifier. The identifier itself is unchanged, so drag payloads are unaffected. - Main app plist: repaired the export — conforms to `public.data`, added a description, dropped the empty tag-specification dict. - Autofill extension plist: added the matching `UTImportedTypeDeclarations` entry. ## Verification - `plutil -lint` passes on both plists. - Full `VaultApp` scheme (app + both extensions) builds for the simulator. - App installed and launched on iPhone 18 Pro Max sim; feed renders (drag/drop types registered) with no UTType fault in the unified log.⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
…567) ## Problem `VaultRoot.vaultStore` used `PersistedLocalVaultStoreFactory.makeVaultStore()`, whose default failure handler is `{ fatalError($0) }`. If the on-disk store failed to open — even after the factory's archive-and-recover pass — the app hard-crashed at launch with no user-facing path. (The widget already used the throwing variant correctly.) ## Fix - New `PersistedLocalVaultStore.inMemory()` public factory: an empty in-memory store used as a safe fallback — keeps the entire static composition graph valid (app, autofill extension, rehash services) while guaranteeing no writes to the broken on-disk store. - `VaultRoot.vaultStore` now calls `makeVaultStoreOrThrow()`; on failure it records `vaultStoreLoadFailureMessage` and returns the in-memory fallback. (`fatalError` remains only for in-memory container creation failing, which has no external failure modes.) - `VaultMainScene` skips `VaultRoot.setup()` when the failure message is set — critical: this prevents the auto-backup wiring from ever backing up the empty fallback vault over a good backup (MANIFESTO C10 blast-radius concern) — and renders the new `VaultStoreFailureView` instead of the vault. - `VaultStoreFailureView` is deliberately static: explains that the unreadable store files were archived beside the store (the factory already does this), advises relaunch or restore from a backup PDF, shows the error line in a Details section. No retry that could write to the broken store, no destructive "start fresh" action (needs its own design — C6), no diagnostics upload (C3). - Autofill extension inherits the fallback automatically: empty store → credential-not-found, no crash. Deeper `protectedDataWillBecomeUnavailable` handling remains a report-only finding for this release. ## Tests - `VaultStoreFailureViewSnapshotTests` — light/dark × 3 type sizes, plus the no-details variant. - Existing `PersistedLocalVaultStoreFactoryTests` (open/recovery/archival behavior) re-run green — the factory itself is unchanged apart from the new in-memory extension. Local verification: targeted suites passed on iPhone 18 Pro Max / iOS 27.0 (snapshots recorded, then clean pass).⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
## Problem
The V1 → V3 schema migration — the path that moves plaintext killphrases
and search passphrases out of the store and into salted-HMAC digests —
was never actually executed by any test.
`PersistedSchemaMigrationPlanTests` asserts only the declaration shape
(two custom stages, `willMigrate != nil`), and every test
`ModelContainer` in the repo omits `migrationPlan:`. The `willMigrate`
closures, the pending-rehash sidecar files, and the Phase B
rehash-on-first-unlock were all unexercised, including the documented
plaintext-on-disk window between the phases.
## Changes (test-only)
New `PersistedSchemaMigrationExecutionTests` — builds a real on-disk V1
store with plaintext phrases, reopens it through the real
`PersistedSchemaMigrationPlan` exactly as the production opener does,
then runs the real rehash services:
- `v1ToV3_migration_writesPendingSidecarsForNonBlankPhrases` —
`willMigrate` snapshots exactly the non-blank `(itemID, phrase)` pairs
into both sidecar files.
- `v1ToV3_migration_skipsBlankAndNilPhrases` — no sidecar entries for
`nil`/empty phrases.
- `rehashServices_consumeSidecarsAndDigestsVerifyOriginalPhrases` —
after `KillphraseRehashService`/`SearchPassphraseRehashService` run:
sidecars are consumed (securely cleared), killphrase deletion fires with
the original phrase (the only public observation point for killphrase
digests — deliberate, MANIFESTO C5), and the passphrase-hidden item is
unreachable without the matcher but returned with it.
- `rehashServices_idempotentWhenSidecarMissing` — writer never invoked
when there is nothing pending.
Each test gets its own temp directory (created in `init`, removed in
`deinit`) to avoid cross-test flake. One real-world catch surfaced while
writing these: the persisted `visibility`/`searchableLevel` strings are
the `VaultEncodingConstants` values ("ALWAYS", "ONLY_PASSPHRASE"), not
the Swift enum case names — the tests now seed with the real constants.
Local verification: suite passes, full VaultFeedTests scheme green on
iPhone 18 Pro Max / iOS 27.0.
⚠️ Automatic CI is still disabled (#548), so this is local verification
only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
## Problem No test anywhere asserted the fail-closed behavior of passphrase-hidden items: when `retrieve(query:searchPassphraseMatcher:)` is called with a `nil` matcher — the real state after a keychain key-load failure leaves `searchPassphraseDigester` nil in `VaultDataModel` — `.onlyPassphrase` items must stay hidden. Only the positive match path was covered. ## Changes (test-only) Two tests in `PersistedLocalVaultStoreTests`, using hidden items whose **titles also match the text query** — so the text predicate alone would leak them if `searchableLevel` were mishandled: - `retrieveMatchingQuery_keepsOnlyPassphraseItemsHiddenWhenMatcherNil` — both the explicit `matcher: nil` call and the `retrieve(query:)` convenience return only the control item. - `retrieveMatchingQuery_keepsOnlyPassphraseItemsHiddenForWrongPhrase` — a present matcher with a non-matching query text also returns only the control item. Local verification: PersistedLocalVaultStoreTests suite green on iPhone 18 Pro Max / iOS 27.0.⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
## Problem `VaultCredentialProviderViewController` (the autofill extension's entry point) had zero tests. Its most security-relevant decision — `provideCredentialWithoutUserInteraction` deciding whether an item can be served to the QuickType bar without any user interaction — lived inline in the UIKit view controller, untestable: the auth gate via `requiresAuthenticationToCopy`, the HOTP refusal (counter must not increment without UI), and an untestable direct `Date()` for TOTP rendering. `VaultAutofillViewModel` was also untested. ## Fix / refactor - New `AutofillOTPCredentialResolver`: the body of `provideOTPCredential` moved verbatim into a small `@MainActor` struct with an `Outcome` enum (`code` / `userInteractionRequired` / `notFound` / `failure`), injected with a retrieval closure, the copy-action handler, and an `EpochClock` (replacing the raw `Date()`). - The VC becomes a thin `Outcome → extensionContext` switch. The refusal paths map to exactly the same indistinct `ASExtensionError`s as before — no new error taxonomy leaking item properties. - Behavior change: none intended; TOTP rendering now uses `VaultRoot.clock` instead of `Date()` (same wall clock in production). ## Tests `AutofillOTPCredentialResolverTests`: - nil / malformed / unknown record identifiers and non-OTP items → `.notFound` - auth-gated item → `.userInteractionRequired` (the C4-relevant gate, now pinned) - HOTP item → `.userInteractionRequired` - TOTP item → code rendered for the injected clock's epoch (deterministic, compared against `TOTPAuthCode.renderCode` directly) - retrieval error → `.failure` `VaultAutofillViewModelTests`: feature routing, dismiss publisher, blank-string filtering on `textToInsertPublisher`, cancel-reason forwarding. VaultiOSAutofillTests goes from 2 tests to 15. Also fixed en route: the `VaultiOSAutofillTests` scheme had no `TestPlanReference` (unlike the other test schemes), so running it standalone ignored `TestPlans/Individual/VaultiOSAutofillTests.xctestplan` and the snapshot locale guard fataled on non-en_US hosts. The scheme now references its plan, matching `VaultiOSTests`. Local verification: VaultiOSAutofillTests scheme green on iPhone 18 Pro Max / iOS 27.0.⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
## Problem The backup pipeline (export → encrypt → attach to PDF → detach → decrypt → import) was covered only as disjoint unit slices across VaultBackupTests. No test proved the composition end-to-end, and nothing proved that duress metadata — killphrase and search-passphrase digests, lock state, searchable level — survives the full trip (MANIFESTO C10). ## Changes (test-only) New `BackupRoundTripTests` in VaultFeedTests (which sits above all the pipeline modules; `Package.swift` gains the explicit `VaultBackup` dependency): - `pdfRoundTrip_merge_preservesDuressMetadata` — seeds a store with a killphrase-armed item, a passphrase-hidden item, a locked item, and a tag; runs the full pipeline including `PDFDocument.dataRepresentation()` → `PDFDocument(data:)` reparse (keeps the trip honest about PDF serialization); imports into a second store and asserts behaviorally: killphrase deletion fires with the original phrase, the hidden item is reachable only through a matching digest, lock state survives, tags survive. - `pdfRoundTrip_override_replacesExistingVault` — import-override drops the destination's pre-existing item and installs the restored set. - `pdfRoundTrip_wrongKey_failsDecrypt` — decryption with the wrong key throws. Uses a directly-constructed `DerivedEncryptionKey` (`.testing` signature), never the multi-minute `Backup.Secure.v1` deriver. Local verification: suite green on iPhone 18 Pro Max / iOS 27.0.⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
## Problem `Backup.Secure.v1` — the deriver that protects stolen backups (PBKDF2 5,452,351 iterations → HKDF-SHA3/512 → scrypt N=2^18) — had no drift protection: only fast derivers have pinned key vectors, because a full vector test of the secure chain costs minutes of KDF per run and would rot skipped. Any accidental edit to the secure parameters would silently break decryption of every existing backup. ## Changes (test-only) New `VaultKeyDeriverParameterPinTests`, exploiting the fact that `uniqueAlgorithmIdentifier` already encodes the complete chain — algorithm order, key length, iterations, variants, cost factors — including nesting via `COMBINATION<...|...>`: - `backupSecureV1_pinsExactKDFChain` / `backupFastV1` / `itemSecureV1` / `itemFastV1` — each pins the exact identifier string. Any parameter drift fails on every CI run at zero KDF cost. A comment records the rule: parameter changes are a new keygen *version* (new signature), never an edit to v1. - `signatureIDs_areStable` — pins the persisted signature raw values (stored in backups and the keychain for decrypt-time lookup). - `lookup_returnsDeriverMatchingEverySignature` — the signature → deriver table stays consistent across all cases. Together with the existing fast pinned vectors in `VaultKeyDeriverTests` (which prove the shared composition machinery produces stable output), this covers secure-parameter drift without minutes of KDF. Local verification: VaultKeygenTests scheme green on iPhone 18 Pro Max / iOS 27.0.⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 15, 2026
## Changes (test-only) — final PR of the pre-release audit series (#561–#572) Snapshot coverage for security-relevant screens rebuilt in #551–#560 that had no view tests: - **`BackupKeyDecryptorViewSnapshotTests`** — initial state (light/dark × 3 type sizes, in a NavigationStack so the Cancel toolbar renders) plus a deterministic decrypt-failure state: the failure is produced by actually running `attemptDecryption()` with the fast testing deriver and an erroring decoder mock, not by faking view state. - **`BackupImportFlowViewSnapshotTests`** — all three `BackupImportContext` variants (empty vault / merge / override), light/dark. - **`VaultDetailEncryptionEditViewSnapshotTests`** — encryption-disabled (full grid) and encryption-enabled variants. - `SettingsDangerView` was covered in #565; `AutoBackupSettingsView` is deliberately not given a standalone suite — it is already snapshotted transitively through `BackupCreateView` (`BackupViewSnapshotTests`), and its enabled/error states are only reachable through an async `.task` handoff that would flake under synchronous snapshot rendering. Driving those states needs a small initial-state injection refactor — left as follow-up. Release gate: full `CI_iOS` scheme (all 13 test targets, `iOSAllTests` plan including the TSAN configuration) run locally on iPhone 18 Pro Max / iOS 27.0.⚠️ Automatic CI is still disabled (#548), so this is local verification only. --- ## Release findings — report-only (no code in this series) The pre-release audit surfaced the following items that need **design decisions**, not patches. Recorded here so they are not lost: **MANIFESTO C7 gaps (protective defaults):** - Clipboard paste TTL defaults to never-expire (`PasteTTL.default = nil`) — copied OTPs/passwords sit on the pasteboard indefinitely unless the user opts in to a TTL. - No screenshot / app-switcher privacy protection anywhere (no `privacySensitive()`, no capture detection, no cover view). - Danger Zone full wipe has no confirmation dialog — one tap + biometric. **Design-level:** - Backups export killphrase/search-passphrase salts+digests; anyone holding the backup password can enumerate which items are duress-protected (C5 tension). - The killphrase/search-passphrase HMAC keys are device-local and not exported, so a restore onto a new device silently disarms every killphrase and permanently hides `.onlyPassphrase` items (rows exist, digests unverifiable). - No app-level lock / auto-lock; background purge clears only the backup password from memory. - Killphrase-triggered auto-backup + widget reload is an out-of-band success signal for a hidden item's deletion (C2 tension). - `payloadHash` and `lastBackupHash` live in plaintext UserDefaults — mutation-time evidence (C6 tension). - `deleteVault()` does not refresh the auto-backup hash, so the newest auto-backup still describes the wiped vault (recovery safety net vs C6 — decide). - `DerivedEncryptionKey.debugDescription` prints raw key material as hex; keychain replace (remove→store) is non-atomic; killphrase/passphrase edit fields are plain `TextField` not `SecureField`; the `vault://` HOTP-increment deep link is unauthenticated; `Data.random` relies on `SystemRandomNumberGenerator` (CSPRNG on Apple platforms, but unannotated as the app's sole randomness source); no `protectedDataWillBecomeUnavailable` handling. **Hygiene (non-blocking):** CI triggers commented out; CHANGELOG ~9 versions stale vs MARKETING_VERSION 2.0; hardcoded strings in rebuilt screens bypass the string catalogs (app is currently English-only, so cosmetic); stale scheme/test-plan references (`CI_iOS` scheme, orphan `VaultUITests` scheme) and a stale snapshot directory; `VaultBackup.xcstrings` not declared as a target resource; the keygen speedtest CLI prints a derived key in hex; feed search reload has no debounce/cancellation; `ForEach` identity built from `Hasher().finalize()`; reorder persist failures are swallowed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 18, 2026
## Summary The feed's bottom bar (tag pills + item count + Clear/Edit) lost its container and its descriptive filter text. These were **two separate changes**, months apart — and neither was #551, which only touched the backup screens: - **The container** — `510bfeaa` (#555) deleted `.background(Color.primary.opacity(0.05))` + `.clipShape(RoundedRectangle(cornerRadius: 12))` from the status row, on the stated grounds that "the controls sit on the content the way a system bottom bar does". The controls were left with no surface at all, so the `LazyVGrid` scrolled directly under the status label. - **The detail** — `315486f5` (#499, January) replaced the localized prose *"2 tag filters"* with a bare `• 🏷 2` badge. For the record: there is **no sort control in the app and never has been** (`git log --all -i -S "sort" -- Sources/VaultiOS` is empty). Order is fixed to `.relativeOrder` at the store layer and rearranged by drag in edit mode. ## What changed - **The container is back exactly where it was**: `Color.primary.opacity(0.05)` + `RoundedRectangle(cornerRadius: 12)` around the **status row only**, inset by 12pt. The tag pills stay **above** it, sitting directly on the content — that is the original arrangement and it is the one restored here. - **A single active filter is named** (`• 🏷 work`). The pill row scrolls horizontally, so an active tag can sit off-screen; the name is information the bare count never carried. With more than one filter there is no room for names inside the container, so it shows the bare count exactly as before (`• 🏷 2`). - **Item count is localized**: `statusLabel` hardcoded English `count == 1 ? "item" : "items"`. It now uses the existing `feedViewModel.searching.title.%lld` plural via a new `VaultDataModel.itemsCountDescription`, mirroring `filteringByTagsDescription`. (That plural lives in VaultFeed's bundle, so it cannot be read from VaultiOS's `localized()`, which resolves against the "Feed" table.) The button styles introduced by #555 (`.bordered` / `.borderedProminent` capsules, `Toggle`-based tag pills) are left alone — those were an accessibility fix, not part of the regression. ### A material was tried and rejected The first attempt replaced the container with a full-width `.bar` material across the whole `safeAreaInset`. Two problems, both visible in the history of this branch: 1. It pulled the tag pills **inside** the band, which is not how the bar is meant to read. 2. `.background(.bar)` applied directly puts the label in a **vibrancy context**. That is not cosmetic — it rendered the `key.horizontal` and `tag.fill` icons completely invisible and forced the secondary text to black. (`.background { Rectangle().fill(.bar) }` avoids the vibrancy, if a material is ever wanted here.) ## Manifesto Reviewed against `MANIFESTO.md`. **C5 / C2** are the live ones and drove an explicit **non-goal**: no "matched of total" count. The feed query filters on `visibility == always` and unions search-passphrase matches only when the query matches (`PersistedLocalVaultStore.swift:42-49, 102-106`), so `items.count` deliberately excludes passphrase-hidden items. Surfacing an unfiltered total would disclose that hidden items exist and let a coercer diff the two numbers. The status label still counts only what the feed already displays, and `itemsCountDescription` carries a doc comment saying so. Nothing else is touched: no bulk operation (C1), no telemetry (C3), no auth change (C4), no undo or audit surface (C6), no default flipped (C7), no step removed from a sensitive flow (C8), no duress feature advertised (C9), no payload change (C10). ## Testing - Full `iOSAllTests` plan passes locally on iPhone 18 Pro Max / iOS 27.0. - Feed snapshots re-recorded and visually compared against `git show 510bfea^:…/unifiedBar_multipleTagsFiltered.1.png` to confirm the layout matches the original. Two added — `unifiedBar_singleFilterIsNamed` and `unifiedBar_multipleFiltersFallBackToCount` — pinning both sides of the naming threshold. - The navigation and autofill suites that embed the feed were re-recorded too.⚠️ Automatic CI is still disabled (#548), so this is local verification only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bradleymackey
added a commit
that referenced
this pull request
Sep 18, 2026
## Summary
Revives a branch that sat unmerged since May and rebases it onto current
`main` (it was 55 commits behind). It makes OTP widgets act in place
instead of bouncing through the app:
- **HOTP: tap the code to advance the counter.**
`IncrementAndCopyHOTPCodeIntent` increments, renders the new code,
copies it, and reloads the timeline — without launching the app.
Previously the only path was the `vault://otp/{id}/increment` deep link,
which opened the app to do it (#517).
- **TOTP: tap the code to copy it.** `CopyTOTPCodeIntent`, also without
launching the app.
- **Tap the issuer/account labels to open the item** in the app, via a
new `openItemDetail` deep link. This is how the app stays reachable from
a widget whose code area is now a button.
- The widget no longer stores an HOTP code in its snapshot. The
persisted counter may already be stale, so every family masks the digits
until the user advances it — a stale code was worse than no code.
## Security decisions
**Home screen only.** The interactive buttons are confined to
`systemSmall`. The `accessoryCircular` and `accessoryRectangular`
families render on the Lock Screen, where a button would be reachable on
a locked device, and advancing an HOTP counter cannot be undone. Those
families keep the existing non-interactive deep link.
**Narrow write capability.** #526 deliberately narrowed the widget's
store to read-only so an extension could never trigger the recovery path
and archive or move the shared SQLite store. That protection is
preserved: the store is still opened `.openOnly`. What changed is the
capability type — `WidgetStore = VaultStoreReader &
VaultStoreHOTPIncrementer`, which grants exactly the counter increment
and nothing else. The extension still cannot insert, update, delete,
reorder, or export.
**Eligibility still gates every action.** Both intents route through
`eligibleItem(id:)`, so a locked, hidden, passphrase-only, or
killphrase-bearing item yields no code — and, for HOTP, no counter
advance. There are tests for each of those cases.
## Manifesto
Reviewed against `MANIFESTO.md`. The corollary that actually bites here
is **C8** — this reduces the steps needed to obtain a code, which is
exactly what C8 says to evaluate rather than wave through. The
judgement: the widget already renders a live TOTP code to anyone looking
at the screen, so tapping to copy discloses nothing the screen did not
already show, and the step being removed is an app launch, not an
authentication. Nothing moved out from behind device auth, because
nothing here was ever behind it. The irreversible action (HOTP
increment) is kept off the Lock Screen for that reason.
**C4** — no auth gate is removed; widget eligibility has always derived
from item state, never from authentication. **C5** — the widget shows
one user-chosen item and enumerates nothing. **C2** — missing, deleted,
and newly-ineligible items still resolve to the same `.unavailable`
state; the intents return an empty result in every failure case, so a
tap reveals nothing about why. **C7** — the copy is `.localOnly` with
the concealed-type marker, matching the app's default posture.
**C1/C3/C6/C9/C10** — untouched.
**One gap worth recording:** the app applies the user's
`pasteTimeToLive` to copies, and the widget cannot read it. Settings
live in standard `UserDefaults`, which an extension does not share, and
`PasteTTL` sits in `VaultSettings`, which the widget target does not
depend on. Since `PasteTTL.default` is `nil` (no expiry), the widget
matches the app's *default* behaviour — but a user who has chosen an
expiry will not get it on widget copies. Closing this needs an App Group
settings suite; it is deliberately not in this PR.
## Rebase notes
Three conflicts, all resolved toward main's current architecture:
`VaultMainScene` (the store-failure screen from #567 now wraps the
navigation view), `WidgetVaultLoader` (main's lazy, retry-safe store
handling kept, capability widened), and `OTPWidgetSmallView` (main's
Dynamic Type fonts kept over the branch's fixed sizes). The accessory
views were taken wholesale from main to keep them non-interactive.
The pre-rebase tip is preserved locally as
`backup/hotp-in-widget-pre-rebase` (`1749dad6`).
## Testing
- Full `iOSAllTests` plan passes locally on iPhone 18 Pro Max / iOS
27.0.
- New `WidgetVaultLoaderCodeActionTests` (11 tests) covers both
intent-facing loader paths: the counter advances exactly once and
renders the *next* counter, a TOTP item is rejected by the HOTP path and
vice versa, an unknown id is inert, and locked/killphrase/hidden items
produce no code and no increment.
- `OTPWidgetLoadingTests` updated for the widened store type.
**Not yet verified on device** — the interactive widget path needs a
manual run: place a `systemSmall` widget on the home screen for an HOTP
item, tap the code, and confirm the counter advances once, the code
lands on the clipboard, and the timeline reloads. I have not done this,
and it is the thing most worth checking before merge.
⚠️ Automatic CI is still disabled (#548), so this is local verification
only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves the project's test baseline to iOS 27.0 and parks automatic CI until a GA runner image can run it.
Why CI ends up disabled
No GitHub-hosted GA image provides Xcode 27.
macos-26tops out at Xcode 26.6 and carries no iOS 27.0 simulator runtime. The betaxcode-27image has both — Lint and CI_iOS Build both went green on it — but its runner pool could not absorb the 13-way test matrix. All 13 shards sat queued, never starting, long after the build had finished.Depending on a beta pool that cannot schedule the work is worse than not running, so the
pull_requestandpushtriggers are commented out with an explanation and a re-enable condition.workflow_dispatchstays, so the suite can still be run on demand from the Actions tab or viagh workflow run validate-all.yml.Everything else in the workflow is already pointed at Xcode 27 and iOS 27.0, and
runs-onstaysmacos-26. Re-enabling is uncommenting two triggers once that image carries Xcode 27.mainhas no automated validation on push or PR. Worth re-checking actions/runner-images periodically.Snapshot migration to iOS 27.0
AssertSnapshotWithDeviceCheck.swiftbumped 26.5 → 27.0Vault/README.mdtesting table updatedThe other 32 were byte-identical across runtimes. Spot-checked the re-recorded images against their predecessors: content is unchanged, the deltas are sub-pixel rendering differences between iOS versions.
Test fixture fix
anyPDFData()built a page-lessPDFDocument(), wrote it, and read it back. That round-trips throughPDFDocument(data:)on iOS 26 but is rejected on iOS 27, failing threeBackupImportFlowViewModeltests withInvalidURLError.Test-fixture defect, not a product one — real export documents always carry pages, and the PDF generator's own snapshot tests (
VaultBackupPDFGeneratorSnapshotTests,PDFDataBlockDocumentRendererSnapshotTests) pass unchanged on iOS 27. The fixture now inserts a page, matching what the app actually produces.Verification
Local, Xcode 27.0 RC1 (
27A266a), iPhone 17 Pro / iOS 27.0:xcodebuild build-for-testing—TEST BUILD SUCCEEDED-parallel-testing-enabled NO—TEST EXECUTE SUCCEEDED, 22 bundle runs (13 bundles × Default and TSAN), 0 failuresmake format+make lint— cleanOn the beta image before the matrix stalled,
Lint,Release ConfigandCI_iOS Buildall passed — so the Xcode 27 configuration itself is sound; only scheduling capacity was the blocker.Unverified
The snapshot references were recorded on Xcode 27.0 RC1. The shards never ran on the beta image, so the references have not been checked against a runner's output. If re-enabled CI reports snapshot mismatches, they will need re-recording from CI rather than locally.
🤖 Generated with Claude Code