Skip to content

Rebuild the feed bottom bar on standard SwiftUI controls - #555

Merged
bradleymackey merged 1 commit into
mainfrom
native-standardization-feed-toolbar
Sep 14, 2026
Merged

bradleymackey merged 1 commit into
mainfrom
native-standardization-feed-toolbar

Conversation

@bradleymackey

Copy link
Copy Markdown
Member

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

@bradleymackey
bradleymackey force-pushed the native-standardization-buttons branch from f43c598 to 83537dd Compare September 14, 2026 16:44
Base automatically changed from native-standardization-buttons to main September 14, 2026 16:48
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
bradleymackey force-pushed the native-standardization-feed-toolbar branch from 4b4f990 to b581376 Compare September 14, 2026 16:49
@bradleymackey
bradleymackey merged commit 510bfea into main Sep 14, 2026
@bradleymackey
bradleymackey deleted the native-standardization-feed-toolbar branch September 14, 2026 16:49
bradleymackey added a commit that referenced this pull request Sep 18, 2026
The bordered button style fills the capsule in both states and
signals selection only through tint, which reads as near-identical
either way. TagPillView fills a selected pill and leaves an
unselected one outlined, which is unambiguous.

Keep the Toggle and .toggleStyle(.button) so the pills retain the
button trait and selected state that #555 added; only the visual
style reverts, via .buttonStyle(.plain) letting TagPillView draw
its own capsule.

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