diff --git a/docs/superpowers/specs/2026-05-21-mypass-design.md b/docs/superpowers/specs/2026-05-21-mypass-design.md index ee4f869..9a22033 100644 --- a/docs/superpowers/specs/2026-05-21-mypass-design.md +++ b/docs/superpowers/specs/2026-05-21-mypass-design.md @@ -34,14 +34,15 @@ MyPassCore (Swift Package) ├── Entry / Group / Attachment — value-type models ├── TOTPGenerator — RFC 6238 TOTP from otp:// custom field ├── VaultSession — owns the live KDBXDatabase; lock/unlock lifecycle -└── KeychainStore — read/write master password in shared Keychain group +├── KeychainStore — read/write master password in shared Keychain group +└── KeePassIconMapper — approximates KeePass's standard iconIndex (0-68) as SF Symbols MyPass (Main App Target) ──links──► MyPassCore ├── UnlockView -├── GroupBrowserView +├── EntryListView — flat, filterable entry list (see UI Navigation) +├── GroupFilterView — sidebar: multi-select group tree, filters EntryListView ├── EntryDetailView ├── EntryEditView -├── SearchView ├── FileBookmarkService — security-scoped bookmark management ├── BiometricAuthService — LAContext wrapper └── ClipboardService — copy with 30 s expiry @@ -135,28 +136,59 @@ struct TOTPConfig { ## UI Navigation -### iOS — NavigationStack +**Revised 2026-09-19** — replaces the original group-drilling design (see git history for the prior version). The main area now shows a flat, filterable list of entries instead of requiring the user to navigate into groups one at a time; groups become a filter, not a place you browse into. + +### Shared structure (iOS + macOS — same component, one implementation) ``` -UnlockView - └─► GroupBrowserView (root group) - ├─► GroupBrowserView (subgroup, pushed recursively) - └─► EntryDetailView - └─► EntryEditView (sheet) +NavigationSplitView(columnVisibility:) +├── sidebar: GroupFilterView — multi-select group tree (see below) +└── detail: NavigationStack + └─► EntryListView (flat, filtered) + └─► EntryDetailView + └─► EntryEditView (sheet) ``` -- Search bar at the top of every `GroupBrowserView` filters entries globally across all groups -- Swipe-to-delete on entries; toolbar `+` button adds an entry in the current group -- Tapping a password field in `EntryDetailView` copies to clipboard (clears after 30 s) -- TOTP code displays with a countdown ring and refreshes automatically +`NavigationSplitView` is used on **both** platforms instead of maintaining separate iOS/macOS navigation code. On iPhone-width layouts it automatically collapses the sidebar into an overlay; on iPad/Mac it can sit persistently alongside the detail column. This behavior is built into the component — no platform-specific branching needed for it. -### macOS — NavigationSplitView (3 columns) +- **Hamburger button** (leading toolbar item, `"line.3.horizontal"`) toggles `columnVisibility` between `.all` and `.detailOnly`, showing/hiding the sidebar. +- **Search icon** (trailing toolbar item, `"magnifyingglass"`) toggles a `.searchable()` bar over `EntryListView`; tapping again hides it and clears the query. +- Swipe-to-delete on entry rows; toolbar `+` button opens `EntryEditView` for a new entry (see "Add-entry flow" below). +- Tapping a password field in `EntryDetailView` copies to clipboard (clears after 30 s). +- TOTP code displays with a countdown ring and refreshes automatically. +- Keyboard shortcuts (macOS): `⌘C` copies password, `⌘⌥C` copies TOTP code, `⌘E` opens edit sheet. -| Sidebar | Middle | Detail | -|---|---|---| -| Group tree (expandable) | Entry list for selected group + search bar | Entry fields + Edit button | +### Entry list (`EntryListView`) -Keyboard shortcuts: `⌘C` copies password, `⌘⌥C` copies TOTP code, `⌘E` opens edit sheet. +Flat list, one row per entry across the whole vault (or the filtered subset — see below). Each row: + +``` +[icon] Title + username · Group > Subgroup > Sub-subgroup +``` + +- **Icon:** always shown, mapped from the entry's `iconIndex` via `KeePassIconMapper` (an approximate SF Symbol per KeePass's standard 0–68 icon palette; unmapped indices fall back to `"key.fill"`). The data model has no custom-icon-image support yet — only the standard palette index — so this is a best-effort visual match, not pixel-accurate. +- **Breadcrumb:** full ancestor path excluding the vault's top-level `root` group (e.g. an entry directly in `root > Work > Email` shows as `Work > Email`, not `Root > Work > Email` — the root node isn't a meaningful group to the user). Truncated in the middle if too long for the row. Computed by a tree-flattening helper (`VaultViewModel.flatEntries: [(entry: Entry, groupPath: [Group])]`) that walks the vault once per access, since entries don't natively carry a back-reference to their parent group. An entry stored directly in the root group (no breadcrumb) just shows the username line with nothing after it. + +### Group filter (`GroupFilterView`, the sidebar) + +A tree of all groups (never entries), each row a `DisclosureGroup` with a leading checkbox-style tap target (`circle` / `checkmark.circle.fill`) — a real `Toggle` isn't used here since it would conflict with the disclosure triangle's own tap target. + +- Selecting a group selects it and its entire subgroup subtree (recursive select-down); deselecting a parent deselects the whole subtree too. +- Selection state (`Set`) lives in `GroupFilterViewModel`, separate from `VaultViewModel` — the sidebar's tree-interaction concerns (expand/collapse, checkbox propagation) are distinct from list-filtering concerns. `ContentView` wires the resulting set into `VaultViewModel.selectedGroupIds`. +- Empty selection = no filter (show everything). A "Clear filter" affordance resets it. + +### Filtering: group selection and search combine with AND + +`VaultViewModel.displayedEntries` applies, in order: (1) group filter — keep only entries whose direct parent group ID is in `selectedGroupIds` (or all entries if empty), then (2) search filter — substring match on title/username/URL, same as the existing behavior. Both active at once narrows the result to their intersection (e.g. select "Work", then search "email" → only Work entries matching "email"). + +### Add-entry flow + +Flattening removes the implicit "current group" that group-drilling used to provide. `EntryEditView` gets an explicit **group picker field** (listing the group tree by full path) instead, defaulting to: the sole selected filter group if exactly one is active, otherwise the vault's root group. The user can change it before saving. `EntryEditViewModel.groupId` becomes a real, always-present field rather than the previous optional-with-silent-failure. + +### Dark mode + +No new work needed — the codebase already uses only semantic SwiftUI colors (`.secondary`, `.tint`, system backgrounds) with zero hardcoded color values, which follow the system appearance automatically. No in-app override setting for now; this may become a settings item later. --- @@ -226,15 +258,20 @@ Entry URL field is matched against the `serviceIdentifier` using host comparison - `TOTPGenerator`: known test vectors from RFC 6238 - `ProtectedString`: obfuscation / reveal symmetry - AutoFill URL matching: table-driven tests covering exact match, subdomain, no-URL entries +- `KeePassIconMapper`: known indices map to expected symbols, unmapped indices fall back correctly ### `MyPassTests` (integration) - Unlock flow with a test KDBX fixture - Add / edit / delete entry persisted to file +- Tree-flattening helper: entries paired with correct full `groupPath`, including nested subgroups +- Group-filter subtree selection: selecting a parent group selects all descendant subgroup IDs; deselecting a parent deselects the whole subtree +- Group filter + search combine as AND: narrowing by group then searching returns only the intersection ### `MyPassUITests` -- Unlock → browse groups → view entry detail → copy password -- Add entry → verify it appears in list +- Unlock → view flat entry list → tap entry → view detail → copy password +- Add entry → verify it appears in list with the group picker's chosen group - Search: query returns expected entries +- Group filter: selecting a group in the sidebar narrows the list; selecting a parent with subgroups includes descendant entries ---