Skip to content

Move test baseline to iOS 27 and disable automatic CI until a GA runner image has Xcode 27 - #548

Merged
bradleymackey merged 2 commits into
mainfrom
maintain/ci-xcode-27
Sep 13, 2026
Merged

bradleymackey merged 2 commits into
mainfrom
maintain/ci-xcode-27

Conversation

@bradleymackey

@bradleymackey bradleymackey commented Sep 13, 2026 •

Copy link
Copy Markdown
Member

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-26 tops out at Xcode 26.6 and carries no iOS 27.0 simulator runtime. The beta xcode-27 image 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_request and push triggers are commented out with an explanation and a re-enable condition. workflow_dispatch stays, so the suite can still be run on demand from the Actions tab or via gh workflow run validate-all.yml.

Everything else in the workflow is already pointed at Xcode 27 and iOS 27.0, and runs-on stays macos-26. Re-enabling is uncommenting two triggers once that image carries Xcode 27.

⚠️ While this is in effect, main has no automated validation on push or PR. Worth re-checking actions/runner-images periodically.

Snapshot migration to iOS 27.0

  • Guard constant in AssertSnapshotWithDeviceCheck.swift bumped 26.5 → 27.0
  • Vault/README.md testing table updated
  • 216 of the 248 reference images re-recorded

The 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-less PDFDocument(), wrote it, and read it back. That round-trips through PDFDocument(data:) on iOS 26 but is rejected on iOS 27, failing three BackupImportFlowViewModel tests with InvalidURLError.

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
  • Full suite, -parallel-testing-enabled NO — TEST EXECUTE SUCCEEDED, 22 bundle runs (13 bundles × Default and TSAN), 0 failures
  • make format + make lint — clean

On the beta image before the matrix stalled, Lint, Release Config and CI_iOS Build all 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

bradleymackey and others added 2 commits September 13, 2026 08:20
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>
@bradleymackey bradleymackey changed the title Move CI to the Xcode 27 runner image Move test baseline to iOS 27 and disable automatic CI until a GA runner image has Xcode 27 Sep 13, 2026
@bradleymackey
bradleymackey merged commit eded0cf into main Sep 13, 2026
@bradleymackey
bradleymackey deleted the maintain/ci-xcode-27 branch September 13, 2026 05:12
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant