Skip to content

Rebuild backup and restore screens on standard SwiftUI form controls - #551

Merged
bradleymackey merged 4 commits into
mainfrom
design/backup-restore-settings
Sep 14, 2026
Merged

bradleymackey merged 4 commits into
mainfrom
design/backup-restore-settings

Conversation

@bradleymackey

@bradleymackey bradleymackey commented Sep 13, 2026 •

Copy link
Copy Markdown
Member

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

bradleymackey and others added 4 commits September 13, 2026 18:48
The backup screens had drifted away from the rest of the app. They were built
from a `ScrollView` of hand-rolled cards, each wrapped in `VaultCardModifier`
with a coloured border, a tinted icon tile, and a full-width filled button
inside the card. Nothing else in the app looks like that — every other screen,
including the note editing flow, uses `Form` with `Section`s, `FormRow`, and
section footers for explanatory copy.

All three screens now use the standard controls:

- `BackupCreateView` — one `Section` per capability, with the explanatory copy
  moved into section footers.
- `BackupRestoreView` — merge and override become plain rows. The override path
  keeps its destructive emphasis through standard red row text rather than a red
  card border and filled button. The "Recommended" capsule badge becomes the
  merge section's header.
- `AutoBackupSettingsView` — now vends a `Section` into its parent's form rather
  than rendering its own card. The status line moved into the section footer,
  and the retention control switched from a segmented picker to the standard
  form picker used elsewhere in settings.
- `BackupKeyChangeView` — password entry becomes ordinary `SecureField` rows,
  keygen status moves to the section footer, and the details disclosure groups
  sit in a normal section.

Behaviour is unchanged throughout. Every action, sheet, task, publisher
subscription and ordering is preserved — including loading the backup password
before presenting any import sheet, which is now done once in a shared row
builder instead of at each call site.

`VaultCardModifier` and `ProminentButtonModifier` are untouched and still used
by the item preview and detail screens.

One copy change: the override warning no longer opens with a "⚠️ Warning!"
prefix, since the red row and the footer already carry that weight. The text
still states that on-device data will be lost if it is not in the backup.

Also fixes a pre-existing defect in BackupKeyChangeViewSnapshotTests. The
scenario loop shared a single view across all six colour-scheme and type-size
combinations, so the first snapshot's `onDisappear` reset `permissionState` to
`.undetermined` and the remaining five silently captured the locked screen
instead of the authenticated one. `layoutAuthenticated` was byte-identical to
`layout`. Each scenario now builds its own view, so the authenticated layout is
actually covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rendered as a list row with a leading icon, the caution read as a tappable
control rather than a note. It is explanatory copy, so it belongs in a footer.

The section now carries its header and footer with no rows, which renders as a
plain paragraph under the heading — the standard treatment for this kind of
note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finishes the design pass. The entry screens were converted already, but the
flows they open were still on the old design, so starting a backup or an import
dropped the user back into bordered cards and full-width filled buttons.

`BackupImportFlowView` was the last screen built entirely from cards. Its root,
its ready-to-import step, and its error and success states are now `Form`
sections. The two import sources each get their own section with a header and a
footer, matching how the entry screens present their options.

The remaining flow screens were already `Form`-based but placed their primary
action inside a section footer as a padded, centred `ProminentButtonModifier`
pill. Those become ordinary button rows:

- `BackupCreatePDFView` — "Make PDF" moves to its own section, and the error
  text moves to that section's footer
- `BackupKeyDecryptorView` — "Decrypt" moves to its own section
- `BackupGeneratedPDFView` — "Export & Save" moves to its own section, and the
  red export reminder becomes that section's footer rather than a bordered card
- `DeviceTransferExportView` — "Try Again" becomes a plain row

Status and terminal states use `PlaceholderView` inside a section, which is what
the other flow screens 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 are untouched and still used by the preview and
detail screens.

Also moves the historical-backup caution into the Details disclosure group on
the backup password screen, alongside About and Keygen Information, rather than
occupying a section of its own.

Behaviour is unchanged throughout: every action, sheet, navigation path, file
importer, publisher and toolbar item is preserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 the section was pure
friction. Entering `needsPasswordEntry` now presents the password sheet
directly and the section is gone.

This needs 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 way 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.

Covers both paths with tests: that cancelling clears the prompt and allows the
same document to prompt again, and that dismissing after a successful decode
leaves the ready payload intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bradleymackey
bradleymackey merged commit e63afbe into main Sep 14, 2026
@bradleymackey
bradleymackey deleted the design/backup-restore-settings branch September 14, 2026 07:01
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 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>
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