Pack Detail parity with Rules — all 10 packs#318
Conversation
Code ReviewOverviewThis PR achieves full Pack Detail / Rules-tab parity for Auto Shift Symbols and Symbol Layer packs by embedding the same Positives
Issues / Suggestions1. In the onSelectPreset: { presetId in
selectedLayerPresetId = presetId // ← written here
Task { await applyLayerPresetEdit(...) }
}And then inside private func applyLayerPresetEdit(presetId: String, ...) async {
selectedLayerPresetId = presetId // ← written again
...
}The double-write is harmless (same value, SwiftUI deduplicates), but it's inconsistent with the HRM/TapHold pattern where local state is set only inside the 2. The empty-string fallback follows the existing 3. No test coverage for the two new The PR description confirms 420 tests pass, but no new tests cover:
The HRM apply path has the same gap, so this is not a regression, but these paths have meaningful side effects (installs, reloads) that are worth at least a happy-path unit test. 4. Comment verbosity in the view body CLAUDE.md calls for comments only when the why is non-obvious. The SummaryThe implementation is solid and correctly extends the established pattern to two previously inconsistent packs. The redundant |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ee9f05d3b9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| Task { await applyAutoShiftEdit(newConfig, collectionID: autoShiftCollection.id) } | ||
| } | ||
| ) | ||
| .id(autoShiftCollection.id) // re-mount when live config changes |
There was a problem hiding this comment.
Re-key Auto Shift editor when config changes
AutoShiftCollectionView stores enabledKeys/timeoutMs/protectFastTyping in @State initialized from config, but this wrapper keys it with .id(autoShiftCollection.id), which never changes as config changes. After refreshInstallState loads a non-default live config (lines 746-748), the child keeps stale defaults, and the next interaction can write those stale values back (e.g., reverting a previously customized timeout). The key should vary with the config (or the child should bind directly) so live state is actually reflected.
Useful? React with 👍 / 👎.
| if let livePreset = liveLayerPresetId() { | ||
| selectedLayerPresetId = livePreset |
There was a problem hiding this comment.
Avoid clobbering pending layer preset during first-touch install
When a user picks a preset while the pack is off, applyLayerPresetEdit installs first and then updates the preset. That install path calls refreshInstallState, and this assignment overwrites selectedLayerPresetId with the pre-change value before the update runs. Because the picker is keyed by selectedLayerPresetId (.id(selectedLayerPresetId ?? "")), the view remounts to the old preset, making the selection appear to revert and potentially leaving the UI out of sync until reopening.
Useful? React with 👍 / 👎.
Audit of all 10 shipped packs showed two gaps where Pack Detail fell through to the generic bindings-list while the Rules tab showed a rich embedded editor: * Auto Shift Symbols — Rules shows AutoShiftCollectionView (per-key toggles + timing slider + fast-typing protection). Pack Detail was showing the pack's illustrative binding samples instead. * Symbol Layer — Rules shows LayerPresetPickerContent (Mirrored / alternatives preset picker). Pack Detail was showing 9 hardcoded Mirrored samples instead. Pack Detail now dispatches both configurations to the same views Rules uses, with live wire-through: edits install the pack on first touch (skipFinalReload), then call the VM method (updateAutoShiftSymbolsConfig or updateCollectionLayerPreset) to persist. Seeds local editor state from the live collection on refreshInstallState so opening Pack Detail after a Rules-tab edit shows the current selection rather than catalog defaults. The other 9 packs already had parity — verified against the dispatch in RulesSummaryView+CollectionRow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ee9f05d to
d397125
Compare
Code ReviewOverviewThis is a well-scoped, purposeful PR. The goal is clear: replace four diverged Pack Detail views with the same SwiftUI components already used in the Rules tab. The approach is consistent with the existing Issues to address before merging1. Missing optimistic update in
Either add a local 2. private func applyLayerPresetEdit(presetId: String, collectionID: UUID) async {
selectedLayerPresetId = presetId // optimistic
if !isInstalled {
await install(skipFinalReload: true) // could call refreshInstallState internally?
}
await kanataManager.updateCollectionLayerPreset(collectionID, presetId: presetId)
}If 3. Fragile anonymous tuple in MappingTableContent(
mappings: collection.mappings.map {
($0.input, $0.output, $0.shiftedOutput, $0.ctrlOutput,
$0.description, $0.sectionBreak, isInstalled, $0.id, nil)
}
)Positional tuples are fragile — if Minor observations (no action required, but worth knowing)Double pattern-match in
Growing The Verbose inline comments A few of the new
An empty-string fallback means SwiftUI sees the same Test coverageNo new tests in the diff, which is expected for pure view-dispatch parity work — testing SwiftUI branch selection is impractical at the unit level. The manual test plan in the PR description covers the golden path. One suggestion: verify that editing in Pack Detail and then navigating to the Rules tab shows the same persisted state (round-trip), since that's the core invariant this PR is establishing. SummaryThe core approach is sound and follows established patterns in the codebase. The two issues worth addressing before merge are the missing optimistic update in |
Closes the two remaining UI gaps from the pack-detail parity audit: * Every embedded editor now wraps in `InsetBackPlane` with the same padding Rules applies (`.padding(.top, 8).padding(.bottom, 12) .padding(.horizontal, 16)`; window snapping uses 12 to match). Packs visually match their Rules-tab counterparts — subtle inner shadow, consistent insets — instead of plain `VStack` + `Divider`. * Home Row Mods pack now shows a `?` help button next to the title that opens the same `MarkdownHelpSheet(resource: "home-row-mods")` Rules uses. Wired via a `helpResourceName` computed property so other packs can opt in later just by naming their markdown. Extracted `embeddedEditor` helper to keep the bindingsBlock dispatch readable now that every case wraps in the same chrome. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — Pack Detail parity with Rules (all 10 packs)OverviewSolid audit work. Finding 4 gaps instead of 2, then fixing all of them in a single PR is the right approach. The core strategy — embed the exact same SwiftUI view the Rules tab uses instead of maintaining a separate custom UI — eliminates an entire class of future drift bugs. The Bugs1. Redundant nil guard inside the help button action ( if helpResourceName != nil { // outer guard
Button {
if helpResourceName != nil { // ← redundant; already guaranteed by outer `if`
showingHomeRowModsHelp = true
}
} ...
}The inner check is dead code — the outer 2. Help sheet is hardcoded, not driven by .sheet(isPresented: $showingHomeRowModsHelp) {
MarkdownHelpSheet(resource: "home-row-mods", title: "Home Row Mods")
}
.sheet(isPresented: $showingHomeRowModsHelp) {
if let resource = helpResourceName {
MarkdownHelpSheet(resource: resource, title: pack.name)
}
}3. In onSelectPreset: { presetId in
selectedLayerPresetId = presetId // set here…
Task { await applyLayerPresetEdit(presetId: presetId, ...) }
}Inside private func applyLayerPresetEdit(presetId: String, ...) async {
selectedLayerPresetId = presetId // …and set again herePick one place. The closure set is synchronous and eager (good for instant UI feedback); the Inconsistency4. Window Snapping uses ID equality while all other new branches use config-type pattern matching // New branches (consistent):
case .autoShiftSymbols = collection.configuration ✅
case .layerPresetPicker = collection.configuration ✅
// Window Snapping (outlier):
collection.id == RuleCollectionIdentifier.windowSnapping ⚠️If this is intentional (e.g., Window Snapping doesn't have a distinct config type that would break for other collections), add a comment explaining why ID equality is used here instead of the pattern-match style. Without it, the next person refactoring this chain will either "fix" it incorrectly or wonder if it's a bug. Minor / Nits5. Lost comment about the TapHold The removed comment explained why updating local state on user click is "harmless":
That's non-obvious. The preserved truncation leaves the 6. An empty string ID is technically valid but semantically opaque. What's well done
Test coverageNo new tests for the 4 added branches. Given these are SwiftUI Summary: Two real bugs (hardcoded sheet resource, redundant double-set), one clear inconsistency to document or fix (Window Snapping ID check), and a few minor nits. Nothing blocking once bugs 1–3 are addressed. |
Changing the Window Snapping convention (or any collection-config tweak) right after toggling a pack on was hitting the 3s TCP reload cooldown and surfacing "Reload delayed: Reload blocked" as a user-facing error toast + error sound. The user's edit was persisted to the config file but never reached kanata until the next unrelated reload — classic "why didn't my change apply" bug. Two fixes in `ConfigReloadCoordinator.triggerTCPReload`: * Cooldown blocks now skip the `configReloadFailed` notification. They are an intentional throttle, not a failure worth surfacing. * When blocked by cooldown, schedule a single deferred retry for ~3.2s later. Rapid successive edits coalesce into one final reload via a tracked `deferredReloadTask` (cancelled on each new block). The config write already landed on disk; this just propagates it to kanata. Real reload failures (validation errors, actual network issues) still notify and toast as before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — Pack Detail parity + ConfigReloadCoordinator deferred retryTwo separate concerns in this diff. Both deserve individual attention. Part 1 —
|
The live keyboard overlay was floating on top of the Gallery window because the two don't know about each other. Mirrors the pattern Settings already uses — `autoHideOnceForSettings` stashes pre-open visibility and `resetSettingsAutoHideGuard` restores it. `GalleryWindowController.showWindow` now: * Calls `autoHideOnceForSettings` after creating the NSWindow. * Attaches a `willCloseNotification` observer scoped to that window, which fires `resetSettingsAutoHideGuard` and cleans itself up. No changes needed on the overlay controller side — reusing existing hooks so Settings, Wizard, and Gallery all share the same pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — Pack Detail parity with Rules (all 10 packs)OverviewSolid structural work. Replacing the bespoke Bugs / Correctness1. Double nil check in the help button action ( The button action re-checks if helpResourceName != nil {
Button {
if helpResourceName != nil { // ← redundant
showingHomeRowModsHelp = true
}
}
}The inner guard is dead code — just 2. Help sheet hardcodes resource instead of using .sheet(isPresented: $showingHomeRowModsHelp) {
MarkdownHelpSheet(resource: "home-row-mods", title: "Home Row Mods")
}The computed 3. In onSelectPreset: { presetId in
selectedLayerPresetId = presetId // set #1
Task { await applyLayerPresetEdit(...) }
}And inside selectedLayerPresetId = presetId // set #2The inline assignment in Code Quality4. -final class GalleryWindowController {
+final class GalleryWindowController: NSObject {The closure-based 5. self?.willCloseObserver.map(NotificationCenter.default.removeObserver)
if let obs = self?.willCloseObserver {
NotificationCenter.default.removeObserver(obs)
}
self?.willCloseObserver = nil6. message.contains("Reload blocked") && message.contains("cooldown")This couples the retry logic to kanata's exact error message strings. A kanata update that rephrases the message silently disables the deferred retry. Worth adding a 7. Magic number for cooldown duration ( try? await Task.sleep(nanoseconds: 3_200_000_000)The 3.2 s value is derived from kanata's 3 s cooldown + slop, but nothing in the code documents that relationship. A named constant ( Minor
What's Good
Priority fixes before merge: issues #1, #2, #3 (correctness / forward-compatibility). The rest are style/robustness nits. |
Mission Control's pack detail (and the matching Rules-tab row) was rendering its chord inputs as raw kanata tokens stacked four-wide — \`LCTL LMET LALT UP\` overflowed the Key column and crashed into the Description column, making both unreadable. Updates `MappingTableContent.formatKeyForDisplay` to detect space- separated inputs (defchordsv2 form) and format each token through the same `macModifiers` map the single-key path uses, joined with no separator. \`Lctl Lmet Lalt Up\` now renders as \`⌃⌘⌥↑\`, matching the Action column's visual vocabulary and fitting comfortably in the Key column. Affects both Rules and Gallery Pack Detail (they share the same view), so the Mission Control row in Rules gets the same cleanup for free. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #318: Pack Detail parity with RulesA thorough audit fixing 4 real gaps (not 2). The approach is correct throughout: embed the same view the Rules tab uses, delegate to the same VM methods, seed local state from the live collection. Below I've broken feedback by file since the diff spans four different areas.
|
| Area | Severity | Item |
|---|---|---|
ConfigReloadCoordinator |
isCooldownBlockMessage string-matching fragility; consider typed error |
|
ConfigReloadCoordinator |
Confirm 3.2s constant tracks ReloadSafetyMonitor's actual window |
|
GalleryWindowController |
Minor | Optional.map as side-effect; prefer if let |
MappingTable |
Minor | Document macModifiers value format assumption |
PackDetailView |
Minor | Remove redundant inner if helpResourceName != nil |
PackDetailView |
Minor | Bind or document the 12 padding constant |
PackDetailView |
Minor | Rename showingHomeRowModsHelp → showingHelpSheet |
The correctness and architecture are solid. The string-matching cooldown detection is the only item I'd want addressed before merge — the rest are polish.
…-multi bug The original Mission Control pack was ergonomically backward — it asked users to press a 4-key chord (Ctrl+Cmd+Option+arrow) to trigger macOS's own 2-key Ctrl+arrow shortcut. That's not a convenience, that's more work than pressing F3. Redesign to match the rest of the Gallery: hold Space (nav layer), then a single letter fires the action. Keys chosen to not collide with Vim Navigation's nav-layer bindings: * m → Mission Control * e → App Exposé * b → Show Desktop * c → Notification Center * [ / ] → Previous / Next Desktop The collection targets the nav layer and provides its own Space activator (dedup'd if Vim Navigation is also on). While implementing, found a pre-existing runtime bug: kanata's `(multi (release-layer X) <modifier-chord> (push-msg ...))` silently swallows the modifier-chord emission. Fix applied in two layers: 1. `convertSingleKeyToForkFormat` was matching against the key-name values (`"lctl"`) instead of the notation prefixes (`"C-"`), so it never expanded anything. Fixed to match prefixes. 2. `wrapWithOneShotExit` now expands modifier-prefixed outputs via the fixed helper before placing them inside the multi wrap. Plain keys and S-expressions pass through untouched. This bug affected every collection whose outputs used `C-`, `M-`, `A-` or `S-` prefixes inside the nav/layer one-shot wrap — potentially silently broken for users of Vim Navigation's modifier-combo mappings (h→M-left, g→M-up, etc.) and any other pack that hit this path. Covered by the Mission Control runtime behavior test now that `space+m` → emits lctl+up correctly. Both Rules and Gallery share the same RuleCollection, so this redesign updates the Mission Control row in both places. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #318: Pack Detail parity with Rules (all 10 packs)OverviewGood, well-scoped PR. The core idea — having Pack Detail embed the exact same SwiftUI views the Rules tab uses, rather than bespoke LazyVGrid read-only versions — is the right call. The Raising a handful of issues below ranging from a silent bug to style/naming nits. 🐛 Bugs / Correctness1. Silent double-set of In onSelectPreset: { presetId in
selectedLayerPresetId = presetId // ← set #1 (sync)
Task { await applyLayerPresetEdit(...) }
}
func applyLayerPresetEdit(presetId: String, ...) async {
selectedLayerPresetId = presetId // ← set #2 (redundant)
...
}This is harmless right now (both values are identical), but 2. private func isCooldownBlockMessage(_ message: String) -> Bool {
message.contains("Reload blocked") && message.contains("cooldown")
}This matches on the kanata server's human-readable error string. If the server wording ever changes ( 3.
private var deferredReloadTask: Task<Void, Never>?
private func scheduleDeferredReloadAfterCooldown() {
deferredReloadTask?.cancel() // read + write
deferredReloadTask = Task { [weak self] in
...
self?.deferredReloadTask = nil // write from Task body
}
}If
|
When Figma is frontmost, holding Space to pan/navigate conflicts with the overlay's hold-Space leader activation. Add a preference for a list of bundle IDs where the live keyboard overlay and the context HUD auto-hide while that app is frontmost, restored when focus moves away. Implementation: * `PreferencesService.overlaySuppressedBundleIDs` (Set<String>), defaults to Figma's two bundle IDs (com.figma.Desktop, com.figma.agent). Posts `.overlaySuppressedBundleIDsChanged` on mutation. * `LiveKeyboardOverlayController.suppressForApp` / `restoreFromAppSuppression` — separate guard from `autoHideOnceForSettings` so Settings and app suppression compose without clobbering each other. * `OverlayAppSuppressor` observes `NSWorkspace.didActivateApplicationNotification` and the preference-change notification, calling suppress/restore. Started from `App.didFinishLaunching` alongside the overlay controller. * `ContextHUDController.showForLayer` bails early if the frontmost app is listed (the HUD is short-lived so an inline check is simpler than a separate suppressor). * Settings → Experimental → "Hide Overlay in Specific Apps" card — lists each configured bundle, shows a nicer display name via `urlForApplication`, adds via the system app picker, removes inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #318: Pack Detail parity with Rules (all 10 packs)OverviewThis PR delivers the intended parity goal and bundles several useful fixes (modifier expansion, deferred reload, overlay suppression, HUD sizing). The core Pack Detail refactor is clean and well-structured. A few things are worth addressing before merge. Bugs / Correctness1. let modifierPrefixes = ["C-", "M-", "A-", "S-", "RA-", "RM-", "RC-", "RS-", "AG-"]
guard modifierPrefixes.contains(where: { trimmed.uppercased().hasPrefix($0) }) else {The prefix list is already uppercase, but 2. private func isCooldownBlockMessage(_ message: String) -> Bool {
message.contains("Reload blocked") && message.contains("cooldown")
}This silently breaks if the Kanata TCP error message format ever changes. The suppressed toast is a real UX behaviour change (user gets no feedback when a write doesn't reach Kanata yet). Recommend a structured error type or at minimum a constant for the expected string, and a comment referencing where that string originates from in the Kanata/TCP layer. 3. try? await Task.sleep(nanoseconds: 3_200_000_000)
guard let self, !Task.isCancelled else { return }Using 4. queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.applyForCurrentApp()
}
}
Code Quality5. PR scope
If any one of these needs a revert, the others come with it. The modifier expansion fix in particular looks like a standalone bug fix that would be safer to merge first. 6. private func appIcon(for _: String) -> String { "app" }The parameter is ignored. Either resolve this (try 7. Help button state naming @State private var showingHomeRowModsHelp = falseBut the sheet is gated by 8. Redundant nil check in header if helpResourceName != nil {
Button {
if helpResourceName != nil { // ← redundant
showingHomeRowModsHelp = true
}
}The inner 9. 9-element tuple in MappingTableContent(
mappings: collection.mappings.map {
($0.input, $0.output, $0.shiftedOutput, $0.ctrlOutput,
$0.description, $0.sectionBreak, isInstalled, $0.id, nil)
}
)The trailing Architecture / Design10. Mission Control behavior change is buried in the PR 11. Test Coverage
Minor Nits
Summary
The core Pack Detail refactor is solid. The main items to address before merge are the 🤖 Generated with Claude Code |
Extends the per-app suppression list to cover the remaining pieces of UI/audio that still fired in Figma: * `LayerIndicatorManager.showLayer` now bails early (and orders the window out) when the frontmost app is on the suppression list, so the "LAYER NAV" badge doesn't pop while you're drawing in Figma. * `SoundManager` gains a single `shouldSuppress()` gate that extends the pre-existing test-environment guard to also cover the app list. Every system sound (tink/glass/beep/warning/submarine/layer-up/ layer-down/overlay-show/overlay-hide) checks it, so no audio feedback sneaks out while the user is in a suppressed app. No new Settings UI needed — everything piggybacks on the existing `overlaySuppressedBundleIDs` preference added in the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #318: Pack Detail parity with Rules (all 10 packs)OverviewThis PR achieves true visual and functional parity between Pack Detail and the Rules tab for all 10 packs. The core strategy — replacing bespoke 🐛 Critical Bug —
|
…recursion) The previous commit did a global replace of `if TestEnvironment.isRunning Tests` → `if shouldSuppress()` inside SoundManager.swift, but one hit was inside `shouldSuppress` itself — turning the guard into a self-call and blowing the stack at bootstrap. Caught at launch by a user crash report: Thread 0 Crashed SoundManager.shouldSuppress() + 12 ... (~522333 recursion levels) ... SoundManager.playLayerUpSound SoundManager.playTinkSound RuleCollectionsManager.regenerateConfigFromCollections RuleCollectionsManager.bootstrap RuntimeCoordinator init Restore the test-env check as the first line of `shouldSuppress` so the gate has a base case and the per-app branch runs only when needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #318: Pack Detail parity with Rules (all 10 packs)OverviewThis PR does solid work: it closes 4 pack-detail/Rules gaps, introduces per-app overlay suppression (Figma default), redesigns Mission Control from 3-modifier chords to Leader + single key, fixes a silent Kanata Bugs / Correctness1.
if let (prefix, modKey) = modifierMap.first(where: { uppercasedKey.hasPrefix($0.prefix) }) {The old code used 2. Cooldown block detection via string matching is fragile
private func isCooldownBlockMessage(_ message: String) -> Bool {
message.contains("Reload blocked") && message.contains("cooldown")
}If Kanata ever changes this error string the deferred-retry silently stops working and the user's config write is lost with no feedback. A typed error enum or a sentinel constant would be more resilient. At minimum, add a comment pointing to the exact Kanata source string this matches. 3.
Also minor: the Code Quality4. Redundant nil-check inside Button action ( if helpResourceName != nil {
Button {
if helpResourceName != nil { // ← already guarded by the outer `if`
showingHomeRowModsHelp = true
}
}Remove the inner guard. 5. The 6. private func appIcon(for _: String) -> String {
"app" // always ignores its argument
}Either inline 7. activationObserver.map(NSWorkspace.shared.notificationCenter.removeObserver)
8. 9-element unnamed tuple in mappings: collection.mappings.map {
($0.input, $0.output, $0.shiftedOutput, $0.ctrlOutput,
$0.description, $0.sectionBreak, isInstalled, $0.id, nil)
}Positional 9-tuples are fragile — if Performance9. if let front = NSWorkspace.shared.frontmostApplication?.bundleIdentifier,
PreferencesService.shared.overlaySuppressedBundleIDs.contains(front)
Architecture / Design10. activationObserver = NSWorkspace.shared.notificationCenter.addObserver(
...
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.applyForCurrentApp()
}
}
11. GalleryWindowController now inherits from NSObject — document why The class gained Mission Control redesign — behavioral noteThe switch from 3-modifier chords to Leader + single key is a big UX improvement. Two things to verify in testing:
Test CoverageThe updated SummaryMust fix before merge: Items 1, 2, 4. The core Pack Detail parity work and the suppressor architecture are solid. The Mission Control redesign is a genuine improvement. Clean up the fragile spots above and this is in good shape. 🤖 Generated with Claude Code |
Summary
Proper audit of all 10 packs against Rules' dispatch (not just config types). Found 4 gaps, not 2.
Rules dispatch has two layers
WindowSnappingView(convention picker + monitor canvas), Function Keys getsFunctionKeysView, Neovim/KindaVim get their ownMappingTableContentfor.tableconfigsWhat was wrong
Wire-through
All edits now call the same VM methods Rules calls:
updateAutoShiftSymbolsConfigupdateCollectionLayerPresetupdateWindowKeyConvention(new in Pack Detail)Install-on-first-touch with
skipFinalReloadso the config update is the single reload. State seeds from live collection onrefreshInstallState.Removed the custom
collectionMappingsBlock(LazyVGrid) — no longer referenced.Other packs verified parity
All 10 packs now embed the exact same SwiftUI view Rules does.
Test plan
swift testpasses (420 tests)🤖 Generated with Claude Code